X-Git-Url: https://git.josue.xyz/?p=crowdnode.js%2F.git;a=blobdiff_plain;f=bin%2Fcrowdnode.js;h=adb6306402070cf5a6c2f3637e55cee0db490aae;hp=e0165a57816568255b02aa6dc318de4cc29e85f8;hb=5dbccedf943f0f768d80a9da1bffff061a5cef3e;hpb=ce14ba4beb779a4a026bbe0e1c869583ca5e572a diff --git a/bin/crowdnode.js b/bin/crowdnode.js index e0165a5..adb6306 100755 --- a/bin/crowdnode.js +++ b/bin/crowdnode.js @@ -5,46 +5,53 @@ require("dotenv").config({ path: ".env" }); require("dotenv").config({ path: ".env.secret" }); +let HOME = process.env.HOME || ""; + +//@ts-ignore let pkg = require("../package.json"); let Fs = require("fs").promises; +let Path = require("path"); +let Cipher = require("./_cipher.js"); let CrowdNode = require("../lib/crowdnode.js"); let Dash = require("../lib/dash.js"); let Insight = require("../lib/insight.js"); +let Prompt = require("./_prompt.js"); let Qr = require("../lib/qr.js"); let Ws = require("../lib/ws.js"); let Dashcore = require("@dashevo/dashcore-lib"); +const DONE = "✅"; +const TODO = "ℹ️"; +const NO_SHADOW = "NONE"; const DUFFS = 100000000; -let qrWidth = 2 + 67 + 2; + +let shownDefault = false; +let qrWidth = 2 + 33 + 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); +let signupOnly = CrowdNode.requests.signupForApi + CrowdNode.requests.offset; +let acceptOnly = CrowdNode.requests.acceptTerms + CrowdNode.requests.offset; +let signupFees = signupOnly + acceptOnly; +let feeEstimate = 500; +let signupTotal = signupFees + 2 * feeEstimate; + +//let paths = {}; +let configdir = `.config/crowdnode`; +let keysDir = Path.join(HOME, `${configdir}/keys`); +let keysDirRel = `~/${configdir}/keys`; +let shadowPath = Path.join(HOME, `${configdir}/shadow`); +let defaultWifPath = Path.join(HOME, `${configdir}/default`); + +function debug() { + //@ts-ignore + console.error.apply(console, arguments); } function showVersion() { @@ -55,21 +62,44 @@ function showVersion() { function showHelp() { showVersion(); + console.info("Quick Start:"); + // technically this also has [--no-reserve] + console.info(" crowdnode stake [addr-or-import-key | --create-new]"); + + console.info(""); 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 status [keyfile-or-addr]"); + console.info(" crowdnode signup [keyfile-or-addr]"); + console.info(" crowdnode accept [keyfile-or-addr]"); + console.info( + " crowdnode deposit [keyfile-or-addr] [dash-amount] [--no-reserve]", + ); console.info( - " crowdnode withdrawal ./privkey.wif # 1-1000 (1.0-100.0%)", + " crowdnode withdrawal [keyfile-or-addr] # 1.0-100.0 (steps by 0.1)", ); 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(" crowdnode balance [keyfile-or-addr]"); // addr + console.info(" crowdnode load [keyfile-or-addr] [dash-amount]"); // addr + console.info( + " crowdnode transfer [dash-amount]", + ); // custom + console.info(""); + + console.info("Key Management & Encryption:"); + console.info(" crowdnode init"); + console.info(" crowdnode generate [--plain-text] [./privkey.wif]"); + console.info(" crowdnode encrypt"); // TODO allow encrypting one-by-one? + console.info(" crowdnode list"); + console.info(" crowdnode use "); + console.info(" crowdnode import "); + //console.info(" crowdnode import <(dash-cli dumpprivkey )"); // TODO + //console.info(" crowdnode export "); // TODO + console.info(" crowdnode passphrase # set or change passphrase"); + console.info(" crowdnode decrypt"); // TODO allow decrypting one-by-one? + console.info(" crowdnode delete "); console.info(""); console.info("CrowdNode HTTP RPC:"); @@ -88,358 +118,1766 @@ function showHelp() { " crowdnode http SetReferral ./privkey.wif ", ); console.info(""); + console.info("Official CrowdNode Resources"); + console.info(""); + console.info("Homepage:"); + console.info(" https://crowdnode.io/"); + console.info(""); + console.info("Terms of Service:"); + console.info(" https://crowdnode.io/terms/"); + console.info(""); + console.info("BlockChain API Guide:"); + console.info( + " https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide", + ); + console.info(""); +} + +let cmds = {}; + +async function main() { + /*jshint maxcomplexity:40 */ + /*jshint maxstatements:500 */ + + // Usage: + // crowdnode [flags] [options] + // Example: + // crowdnode withdrawal ./Xxxxpubaddr.wif 100.0 + + let args = process.argv.slice(2); + + // flags + let forceGenerate = removeItem(args, "--create-new"); + let forceConfirm = removeItem(args, "--unconfirmed"); + let plainText = removeItem(args, "--plain-text"); + 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; + } + + // + // + // find addr by name or by file or by string + await Fs.mkdir(keysDir, { + recursive: true, + }); + + let defaultAddr = await Fs.readFile(defaultWifPath, "utf8").catch( + emptyStringOnErrEnoent, + ); + defaultAddr = defaultAddr.trim(); + + let insightBaseUrl = + process.env.INSIGHT_BASE_URL || "https://insight.dash.org"; + let insightApi = Insight.create({ baseUrl: insightBaseUrl }); + let dashApi = Dash.create({ insightApi: insightApi }); + + if ("stake" === subcommand) { + await stakeDash( + { + dashApi, + insightApi, + insightBaseUrl, + defaultAddr, + forceGenerate, + noReserve, + }, + args, + ); + process.exit(0); + return; + } + + if ("list" === subcommand) { + await listKeys({ dashApi, defaultAddr }, args); + process.exit(0); + return; + } + + if ("init" === subcommand) { + await initKeystore({ defaultAddr }); + process.exit(0); + return; + } + + if ("generate" === subcommand) { + await generateKey({ defaultKey: defaultAddr, plainText }, args); + process.exit(0); + return; + } + + if ("passphrase" === subcommand) { + await setPassphrase({}, args); + process.exit(0); + return; + } + + if ("import" === subcommand) { + let keypath = args.shift() || ""; + await importKey({ keypath }); + process.exit(0); + return; + } + + if ("encrypt" === subcommand) { + let addr = args.shift() || ""; + if (!addr) { + await encryptAll(null); + process.exit(0); + return; + } + + let keypath = await findWif(addr); + if (!keypath) { + console.error(`no managed key matches '${addr}'`); + process.exit(1); + return; + } + let key = await maybeReadKeyFileRaw(keypath); + if (!key) { + throw new Error("impossible error"); + } + await encryptAll([key]); + process.exit(0); + return; + } + + if ("decrypt" === subcommand) { + let addr = args.shift() || ""; + if (!addr) { + await decryptAll(null); + await Fs.writeFile(shadowPath, NO_SHADOW, "utf8").catch( + emptyStringOnErrEnoent, + ); + process.exit(0); + return; + } + let keypath = await findWif(addr); + if (!keypath) { + console.error(`no managed key matches '${addr}'`); + process.exit(1); + return; + } + let key = await maybeReadKeyFileRaw(keypath); + if (!key) { + throw new Error("impossible error"); + } + await decryptAll([key]); + process.exit(0); + return; + } + + // use or select or default... ? + if ("use" === subcommand) { + await setDefault(null, args); + process.exit(0); + return; + } + + // helper for debugging + if ("transfer" === subcommand) { + await transferBalance( + { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi }, + args, + ); + process.exit(0); + return; + } + + let rpc = ""; + if ("http" === subcommand) { + rpc = args.shift() || ""; + if (!rpc) { + showHelp(); + process.exit(1); + return; + } + + let [addr] = await mustGetAddr({ defaultAddr }, args); + + await initCrowdNode(insightBaseUrl); + // ex: http (, ...) + args.unshift(addr); + let hasRpc = rpc in CrowdNode.http; + if (!hasRpc) { + console.error(`Unrecognized rpc command ${rpc}`); + console.error(); + showHelp(); + process.exit(1); + } + //@ts-ignore - TODO use `switch` or make Record Type + let result = await CrowdNode.http[rpc].apply(null, args); + console.info(``); + console.info(`${rpc} ${addr}:`); + if ("string" === typeof result) { + console.info(result); + } else { + console.info(JSON.stringify(result, null, 2)); + } + process.exit(0); + return; + } + + if ("load" === subcommand) { + await loadAddr({ defaultAddr, insightBaseUrl }, args); + process.exit(0); + return; + } + + // keeping rm for backwards compat + if ("rm" === subcommand || "delete" === subcommand) { + await initCrowdNode(insightBaseUrl); + let [addr, filepath] = await mustGetAddr({ defaultAddr }, args); + await removeKey({ addr, dashApi, filepath, insightBaseUrl }, args); + process.exit(0); + return; + } + + if ("balance" === subcommand) { + if (args.length) { + await getBalance({ dashApi, defaultAddr }, args); + process.exit(0); + return; + } + + await getAllBalances({ dashApi, defaultAddr }, args); + process.exit(0); + return; + } + + if ("status" === subcommand) { + await getStatus({ dashApi, defaultAddr, insightBaseUrl }, args); + process.exit(0); + return; + } + + if ("signup" === subcommand) { + await sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args); + process.exit(0); + return; + } + + if ("accept" === subcommand) { + await acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args); + process.exit(0); + return; + } + + if ("deposit" === subcommand) { + await depositDash( + { dashApi, defaultAddr, insightBaseUrl, noReserve }, + args, + ); + process.exit(0); + return; + } + + if ("withdrawal" === subcommand) { + await withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args); + process.exit(0); + return; + } + + console.error(`Unrecognized subcommand ${subcommand}`); + console.error(); + showHelp(); + process.exit(1); +} + +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {Boolean} opts.forceGenerate + * @param {String} opts.insightBaseUrl + * @param {any} opts.insightApi + * @param {Boolean} opts.noReserve + * @param {Array} args + */ +async function stakeDash( + { + dashApi, + defaultAddr, + forceGenerate, + insightApi, + insightBaseUrl, + noReserve, + }, + args, +) { + let err = await Fs.access(args[0]).catch(Object); + let addr; + if (!err) { + let keypath = args.shift() || ""; + addr = await importKey({ keypath }); + } else if (forceGenerate) { + addr = await generateKey({ defaultKey: defaultAddr }, []); + } else { + addr = await initKeystore({ defaultAddr }); + } + + if (!addr) { + let [_addr] = await mustGetAddr({ defaultAddr }, args); + addr = _addr; + } + + let extra = feeEstimate; + console.info("Checking CrowdNode account... "); + await CrowdNode.init({ + baseUrl: "https://app.crowdnode.io", + insightBaseUrl, + }); + let hotwallet = CrowdNode.main.hotwallet; + let state = await getCrowdNodeStatus({ addr, hotwallet }); + + if (!state.status?.accept) { + if (!state.status?.signup) { + let signUpDeposit = signupOnly + feeEstimate; + console.info( + ` ${TODO} SignUpForApi deposit is ${signupOnly} (+ tx fee)`, + ); + extra += signUpDeposit; + } else { + console.info(` ${DONE} SignUpForApi complete`); + } + let acceptDeposit = acceptOnly + feeEstimate; + console.info(` ${TODO} AcceptTerms deposit is ${acceptOnly} (+ tx fee)`); + extra += acceptDeposit; + } + + let desiredAmountDash = args.shift() || "0.5"; + let effectiveDuff = toDuff(desiredAmountDash); + effectiveDuff += extra; + + let balanceInfo = await dashApi.getInstantBalance(addr); + effectiveDuff -= balanceInfo.balanceSat; + + if (effectiveDuff > 0) { + effectiveDuff = roundDuff(effectiveDuff, 3); + let effectiveDash = toDash(effectiveDuff); + await plainLoadAddr({ + addr, + effectiveDash, + effectiveDuff, + insightBaseUrl, + }); + } + + if (!state.status?.accept) { + if (!state.status?.signup) { + await sendSignup({ dashApi, defaultAddr: addr, insightBaseUrl }, [addr]); + } + await acceptTerms({ dashApi, defaultAddr: addr, insightBaseUrl }, [addr]); + } + + await depositDash( + { dashApi, defaultAddr: addr, insightBaseUrl, noReserve }, + [addr].concat(args), + ); +} + +/** + * @param {Object} opts + * @param {String} opts.defaultAddr + */ +async function initKeystore({ defaultAddr }) { + // if we have no keys, make one + let wifnames = await listManagedKeynames(); + if (!wifnames.length) { + return await generateKey({ defaultKey: defaultAddr }, []); + } + // if we have no passphrase, ask about it + await initPassphrase(); + return defaultAddr || wifnames[0]; +} + +/** + * @param {String} insightBaseUrl + */ +async function initCrowdNode(insightBaseUrl) { + if (CrowdNode.main.hotwallet) { + return; + } + process.stdout.write("Checking CrowdNode API... "); + await CrowdNode.init({ + baseUrl: "https://app.crowdnode.io", + insightBaseUrl, + }); + console.info(`(hotwallet ${CrowdNode.main.hotwallet})`); +} + +/** + * @param {String} addr - Base58Check pubKeyHash address + * @param {Number} duffs - 1/100000000 of a DASH + */ +function showQr(addr, duffs = 0) { + let dashAmount = toDash(duffs); + let dashUri = `dash://${addr}`; + if (duffs) { + dashUri += `?amount=${dashAmount}`; + } + + let dashQr = Qr.ascii(dashUri, { indent: 4, size: "mini" }); + let addrPad = Math.max(0, Math.ceil((qrWidth - dashUri.length) / 2)); + + console.info(dashQr); + console.info(); + console.info(" ".repeat(addrPad) + dashUri); +} + +/** + * @param {Array} arr + * @param {any} item + */ +function removeItem(arr, item) { + let index = arr.indexOf(item); + if (index >= 0) { + return arr.splice(index, 1)[0]; + } + return null; +} + +/** + * @param {Object} opts + * @param {String} opts.addr + * @param {String} opts.hotwallet + */ +async function getCrowdNodeStatus({ addr, hotwallet }) { + let state = { + signup: TODO, + accept: TODO, + deposit: TODO, + status: { + signup: 0, + accept: 0, + deposit: 0, + }, + }; + + //@ts-ignore - TODO why warnings? + let status = await CrowdNode.status(addr, hotwallet); + if (status) { + state.status = status; + } + if (state.status?.signup) { + state.signup = DONE; + } + if (state.status?.accept) { + state.accept = DONE; + } + if (state.status?.deposit) { + state.deposit = DONE; + } + return state; +} + +/** + * @param {Object} opts + * @param {String} opts.addr + * @param {any} opts.dashApi - TODO + */ +async function checkBalance({ addr, dashApi }) { + // deposit if balance is over 100,000 (0.00100000) + console.info("Checking balance... "); + let balanceInfo = await dashApi.getInstantBalance(addr); + let balanceDASH = toDASH(balanceInfo.balanceSat); + + let crowdNodeBalance = await CrowdNode.http.GetBalance(addr); + if (!crowdNodeBalance.TotalBalance) { + crowdNodeBalance.TotalBalance = 0; + crowdNodeBalance.TotalDividend = 0; + } + + let crowdNodeDuffNum = toDuff(crowdNodeBalance.TotalBalance); + let crowdNodeDASH = toDASH(crowdNodeDuffNum); + + let crowdNodeDivNum = toDuff(crowdNodeBalance.TotalDividend); + let crowdNodeDASHDiv = toDASH(crowdNodeDivNum); + + console.info(`Key: ${balanceDASH}`); + console.info(`CrowdNode: ${crowdNodeDASH}`); + console.info(`Dividends: ${crowdNodeDASHDiv}`); + console.info(); + /* + 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; + } + } + } + */ + return balanceInfo; +} + +/** + * @param {Object} opts + * @param {String} opts.defaultAddr + * @param {Array} args + * @returns {Promise<[String, String]>} + */ +async function mustGetAddr({ defaultAddr }, args) { + let name = args.shift() ?? ""; + if (34 === name.length) { + // looks like addr already + // TODO make function for addr-lookin' check + return [name, name]; + } + + let addr = await maybeReadKeyPaths(name, { wif: false }); + if (addr) { + if (34 === addr.length) { + return [addr, name]; + } + //let pk = new Dashcore.PrivateKey(wif); + //let addr = pk.toAddress().toString(); + return [addr, name]; + } + + let isNum = !isNaN(parseFloat(name)); + if (isNum) { + args.unshift(name); + name = ""; + } + + if (name) { + console.error(); + console.error(`could not read '${name}' in ./ or match in ${keysDirRel}/.`); + console.error(); + process.exit(1); + return ["", name]; + } + + addr = await mustGetDefaultWif(defaultAddr, { wif: false }); + + // TODO we don't need defaultAddr, right? because it could be old? + return [addr, addr]; +} + +/** + * @param {Object} opts + * @param {String} opts.defaultAddr + * @param {Array} args + */ +async function mustGetWif({ defaultAddr }, args) { + let name = args.shift() ?? ""; + + let wif = await maybeReadKeyPaths(name, { wif: true }); + if (wif) { + return wif; + } + + let isNum = !isNaN(parseFloat(name)); + if (isNum) { + args.unshift(name); + name = ""; + } + + if (name) { + console.error(); + console.error( + `'${name}' does not match a staking key in ./ or ${keysDirRel}/`, + ); + console.error(); + process.exit(1); + return ""; + } + + wif = await mustGetDefaultWif(defaultAddr); + + return wif; +} + +/** + * @param {String} name + * @param {Object} opts + * @param {Boolean} opts.wif + * @returns {Promise} - wif + */ +async function maybeReadKeyPaths(name, opts) { + let privKey = ""; + + // prefix match in .../keys/ + let wifname = await findWif(name); + if (!wifname) { + return ""; + } + + if (false === opts.wif) { + return wifname.slice(0, -".wif".length); + } + + let filepath = Path.join(keysDir, wifname); + privKey = await maybeReadKeyFile(filepath); + if (!privKey) { + // local in ./ + privKey = await maybeReadKeyFile(name); + } + + return privKey; +} + +/** + * @param {String} defaultAddr + * @param {Object} [opts] + * @param {Boolean} opts.wif + */ +async function mustGetDefaultWif(defaultAddr, opts) { + let defaultWif = ""; + if (defaultAddr) { + let keyfile = Path.join(keysDir, `${defaultAddr}.wif`); + let raw = await maybeReadKeyFileRaw(keyfile, opts); + // misnomering wif here a bit + defaultWif = raw?.wif || raw?.addr || ""; + } + if (defaultWif && !shownDefault) { + shownDefault = true; + debug(`Selected default staking key ${defaultAddr}`); + return defaultWif; + } + + console.error(); + console.error(`Error: no default staking key selected.`); + console.error(); + console.error(`Select a different address:`); + console.error(` crowdnode list`); + console.error(` crowdnode use `); + console.error(``); + console.error(`Or create a new staking key:`); + console.error(` crowdnode generate`); + console.error(); + process.exit(1); + return ""; +} + +// Subcommands + +/** + * @param {Object} psuedoState + * @param {String} psuedoState.defaultKey - addr name of default key + * @param {Boolean} [psuedoState.plainText] - don't encrypt + * @param {Array} args + */ +async function generateKey({ defaultKey, plainText }, args) { + let name = args.shift(); + //@ts-ignore - TODO submit JSDoc PR for Dashcore + let pk = new Dashcore.PrivateKey(); + + let addr = pk.toAddress().toString(); + let plainWif = pk.toWIF(); + + let wif = plainWif; + if (!plainText) { + wif = await maybeEncrypt(plainWif); + } + + let filename = `~/${configdir}/keys/${addr}.wif`; + let filepath = Path.join(`${keysDir}/${addr}.wif`); + let note = ""; + if (name) { + filename = name; + filepath = name; + note = `\n(for pubkey address ${addr})`; + let err = await Fs.access(filepath).catch(Object); + if (!err) { + // TODO + console.info(`'${filepath}' already exists (will not overwrite)`); + process.exit(0); + return; + } + } + + await Fs.writeFile(filepath, wif, "utf8"); + if (!name && !defaultKey) { + await Fs.writeFile(defaultWifPath, addr, "utf8"); + } + + console.info(``); + console.info(`Generated ${filename} ${note}`); + console.info(``); + return addr; +} + +async function initPassphrase() { + let needsInit = false; + let shadow = await Fs.readFile(shadowPath, "utf8").catch( + emptyStringOnErrEnoent, + ); + if (!shadow) { + needsInit = true; + } + if (needsInit) { + await cmds.getPassphrase({}, []); + } +} + +/** + * @param {Object} state + * @param {Boolean} [state._askPreviousPassphrase] - don't ask for passphrase again + * @param {Array} args + */ +async function setPassphrase({ _askPreviousPassphrase }, args) { + let result = { + passphrase: "", + changed: false, + }; + let date = getFsDateString(); + + // get the old passphrase + if (false !== _askPreviousPassphrase) { + // TODO should contain the shadow? + await cmds.getPassphrase({ _rotatePassphrase: true }, []); + } + + // get the new passphrase + let newPassphrase = await promptPassphrase(); + let curShadow = await Fs.readFile(shadowPath, "utf8").catch( + emptyStringOnErrEnoent, + ); + + let newShadow = await Cipher.shadowPassphrase(newPassphrase); + await Fs.writeFile(shadowPath, newShadow, "utf8"); + + let rawKeys = await readAllKeys(); + let encAddrs = rawKeys + .map(function (raw) { + if (raw.encrypted) { + return raw.addr; + } + }) + .filter(Boolean); + + // backup all currently encrypted files + //@ts-ignore + if (encAddrs.length) { + let filepath = Path.join(HOME, `${configdir}/keys.${date}.bak`); + console.info(``); + console.info(`Backing up previous (encrypted) keys:`); + encAddrs.unshift(`SHADOW:${curShadow}`); + await Fs.writeFile(filepath, encAddrs.join("\n") + "\n", "utf8"); + console.info(` ~/${configdir}/keys.${date}.bak`); + console.info(``); + } + cmds._setPassphrase(newPassphrase); + + await encryptAll(rawKeys, { rotateKey: true }); + + result.passphrase = newPassphrase; + result.changed = true; + return result; +} + +async function promptPassphrase() { + let newPassphrase; + for (;;) { + newPassphrase = await Prompt.prompt("Enter (new) passphrase: ", { + mask: true, + }); + newPassphrase = newPassphrase.trim(); + + let _newPassphrase = await Prompt.prompt("Enter passphrase again: ", { + mask: true, + }); + _newPassphrase = _newPassphrase.trim(); + + let match = Cipher.secureCompare(newPassphrase, _newPassphrase); + if (match) { + break; + } + + console.error("passphrases do not match"); + } + return newPassphrase; +} + +/** + * Import and Encrypt + * @param {Object} opts + * @param {String} opts.keypath + */ +async function importKey({ keypath }) { + let key = await maybeReadKeyFileRaw(keypath); + if (!key?.wif) { + console.error(`no key found for '${keypath}'`); + process.exit(1); + return; + } + + let encWif = await maybeEncrypt(key.wif); + let icon = "💾"; + if (encWif.includes(":")) { + icon = "🔐"; + } + let date = getFsDateString(); + + await safeSave( + Path.join(keysDir, `${key.addr}.wif`), + encWif, + Path.join(keysDir, `${key.addr}.${date}.bak`), + ); + + console.info(`${icon} Imported ${keysDirRel}/${key.addr}.wif`); + console.info(``); + + return key.addr; +} + +/** + * @param {Object} opts + * @param {Boolean} [opts._rotatePassphrase] + * @param {Boolean} [opts._force] + * @param {Array} args + */ +cmds.getPassphrase = async function ({ _rotatePassphrase, _force }, args) { + let result = { + passphrase: "", + changed: false, + }; + /* + if (!_rotatePassphrase) { + let cachedphrase = cmds._getPassphrase(); + if (cachedphrase) { + return cachedphrase; + } + } + */ + + // Three possible states: + // 1. no shadow file yet (ask to set one) + // 2. empty shadow file (initialized, but not set - don't ask to set one) + // 3. encrypted shadow file (initialized, requires passphrase) + let needsInit = false; + let shadow = await Fs.readFile(shadowPath, "utf8").catch( + emptyStringOnErrEnoent, + ); + if (!shadow) { + needsInit = true; + } else if (NO_SHADOW === shadow && _force) { + needsInit = true; + } + + // State 1: not initialized, what does the user want? + if (needsInit) { + for (;;) { + let no; + if (!_force) { + no = await Prompt.prompt( + "Would you like to set an encryption passphrase? [Y/n]: ", + ); + } + + // Set a passphrase and create shadow file + if (!no || ["yes", "y"].includes(no.toLowerCase())) { + result = await setPassphrase({ _askPreviousPassphrase: false }, args); + cmds._setPassphrase(result.passphrase); + return result; + } + + // ask user again + if (!["no", "n"].includes(no.toLowerCase())) { + continue; + } + + // No passphrase, create a NONE shadow file + await Fs.writeFile(shadowPath, NO_SHADOW, "utf8"); + return result; + } + } + + // State 2: shadow already initialized to empty + // (user doesn't want a passphrase) + if (!shadow) { + cmds._setPassphrase(""); + return result; + } + + // State 3: passphrase & shadow already in use + for (;;) { + let prompt = `Enter passphrase: `; + if (_rotatePassphrase) { + prompt = `Enter (current) passphrase: `; + } + result.passphrase = await Prompt.prompt(prompt, { + mask: true, + }); + result.passphrase = result.passphrase.trim(); + if (!result.passphrase || "q" === result.passphrase) { + console.error("cancel: no passphrase"); + process.exit(1); + return result; + } + + let match = await Cipher.checkPassphrase(result.passphrase, shadow); + if (match) { + cmds._setPassphrase(result.passphrase); + console.info(``); + return result; + } + + console.error("incorrect passphrase"); + } + + throw new Error("SANITY FAIL: unreachable return"); +}; + +cmds._getPassphrase = function () { + return ""; +}; + +/** + * @param {String} passphrase + */ +cmds._setPassphrase = function (passphrase) { + // Look Ma! A private variable! + cmds._getPassphrase = function () { + return passphrase; + }; +}; + +/** + * Encrypt ALL-the-things! + * @param {Object} [opts] + * @param {Boolean} opts.rotateKey + * @param {Array?} rawKeys + */ +async function encryptAll(rawKeys, opts) { + if (!rawKeys) { + rawKeys = await readAllKeys(); + } + let date = getFsDateString(); + + let passphrase = cmds._getPassphrase(); + if (!passphrase) { + let result = await cmds.getPassphrase({ _force: true }, []); + if (result.changed) { + // encryptAll was already called on rotation + return; + } + passphrase = result.passphrase; + } + + console.info(`Encrypting...`); + console.info(``); + await rawKeys.reduce(async function (promise, key) { + await promise; + + if (key.encrypted && !opts?.rotateKey) { + console.info(`🙈 ${key.addr} [already encrypted]`); + return; + } + let encWif = await maybeEncrypt(key.wif, { force: true }); + await safeSave( + Path.join(keysDir, `${key.addr}.wif`), + encWif, + Path.join(keysDir, `${key.addr}.${date}.bak`), + ); + console.info(`🔑 ${key.addr}`); + }, Promise.resolve()); + console.info(``); + console.info(`Done 🔐`); + console.info(``); +} + +/** + * Decrypt ALL-the-things! + * @param {Array?} rawKeys + */ +async function decryptAll(rawKeys) { + if (!rawKeys) { + rawKeys = await readAllKeys(); + } + let date = getFsDateString(); + + console.info(``); + console.info(`Decrypting...`); + console.info(``); + await rawKeys.reduce(async function (promise, key) { + await promise; + + if (!key.encrypted) { + console.info(`📖 ${key.addr} [already decrypted]`); + return; + } + await safeSave( + Path.join(keysDir, `${key.addr}.wif`), + key.wif, + Path.join(keysDir, `${key.addr}.${date}.bak`), + ); + console.info(`🔓 ${key.addr}`); + }, Promise.resolve()); + console.info(``); + console.info(`Done ${DONE}`); + console.info(``); +} + +function getFsDateString() { + // YYYY-MM-DD_hh-mm_ss + let date = new Date() + .toISOString() + .replace(/:/g, ".") + .replace(/T/, "_") + .replace(/\.\d{3}.*/, ""); + return date; +} + +/** + * @param {String} filepath + * @param {String} wif + * @param {String} bakpath + */ +async function safeSave(filepath, wif, bakpath) { + let tmpPath = `${bakpath}.tmp`; + await Fs.writeFile(tmpPath, wif, "utf8"); + let err = await Fs.access(filepath).catch(Object); + if (!err) { + await Fs.rename(filepath, bakpath); + } + await Fs.rename(tmpPath, filepath); + if (!err) { + await Fs.unlink(bakpath); + } +} + +/** + * @typedef {Object} RawKey + * @property {String} addr + * @property {Boolean} encrypted + * @property {String} wif + */ + +/** + * @throws + */ +async function readAllKeys() { + let wifnames = await listManagedKeynames(); + + /** @type Array */ + let keys = []; + await wifnames.reduce(async function (promise, wifname) { + await promise; + + let keypath = Path.join(keysDir, wifname); + let key = await maybeReadKeyFileRaw(keypath); + if (!key?.wif) { + return; + } + + if (`${key.addr}.wif` !== wifname) { + throw new Error( + `computed pubkey '${key.addr}' of WIF does not match filename '${keypath}'`, + ); + } + + keys.push(key); + }, Promise.resolve()); + + return keys; +} + +/** + * @param {String} filepath + * @param {Object} [opts] + * @param {Boolean} opts.wif + * @returns {Promise} + */ +async function maybeReadKeyFile(filepath, opts) { + let key = await maybeReadKeyFileRaw(filepath, opts); + if (false === opts?.wif) { + return key?.addr || ""; + } + return key?.wif || ""; +} + +/** + * @param {String} filepath + * @param {Object} [opts] + * @param {Boolean} opts.wif + * @returns {Promise} + */ +async function maybeReadKeyFileRaw(filepath, opts) { + let privKey = await Fs.readFile(filepath, "utf8").catch( + emptyStringOnErrEnoent, + ); + privKey = privKey.trim(); + if (!privKey) { + return null; + } + + let encrypted = false; + if (privKey.includes(":")) { + encrypted = true; + try { + if (false !== opts?.wif) { + privKey = await decrypt(privKey); + } + } catch (err) { + //@ts-ignore + console.error(err.message); + console.error(`passphrase does not match for key ${filepath}`); + process.exit(1); + } + } + if (false === opts?.wif) { + return { + addr: Path.basename(filepath, ".wif"), + encrypted: encrypted, + wif: "", + }; + } + + let pk = new Dashcore.PrivateKey(privKey); + let pub = pk.toAddress().toString(); + + return { + addr: pub, + encrypted: encrypted, + wif: privKey, + }; +} + +/** + * @param {String} encWif + */ +async function decrypt(encWif) { + let passphrase = cmds._getPassphrase(); + if (!passphrase) { + let result = await cmds.getPassphrase({}, []); + passphrase = result.passphrase; + // we don't return just in case they're setting a passphrase to + // decrypt a previously encrypted file (i.e. for recovery from elsewhere) + } + let key128 = await Cipher.deriveKey(passphrase); + let cipher = Cipher.create(key128); + + return cipher.decrypt(encWif); } -function removeItem(arr, item) { - let index = arr.indexOf(item); - if (index >= 0) { - return arr.splice(index, 1)[0]; +// tuple example {Promise<[String, Boolean]>} +/** + * @param {Object} [opts] + * @param {Boolean} [opts.force] + * @param {String} plainWif + */ +async function maybeEncrypt(plainWif, opts) { + let passphrase = cmds._getPassphrase(); + if (!passphrase) { + let result = await cmds.getPassphrase({}, []); + passphrase = result.passphrase; } - return null; + if (!passphrase) { + if (opts?.force) { + throw new Error(`no passphrase with which to encrypt file`); + } + return plainWif; + } + + let key128 = await Cipher.deriveKey(passphrase); + let cipher = Cipher.create(key128); + return cipher.encrypt(plainWif); } -async function main() { - // Usage: - // crowdnode [flags] [options] - // Example: - // crowdnode withdrawal --unconfirmed ./Xxxxpubaddr.wif 1000 +/** + * @param {Null} _ + * @param {Array} args + */ +async function setDefault(_, args) { + let addr = args.shift() || ""; - let args = process.argv.slice(2); + let keyname = await findWif(addr); + if (!keyname) { + console.error(`no key matches '${addr}'`); + process.exit(1); + return; + } - // flags - let forceConfirm = removeItem(args, "--unconfirmed"); - let noReserve = removeItem(args, "--no-reserve"); + let filepath = Path.join(keysDir, keyname); + let wif = await maybeReadKeyFile(filepath); + let pk = new Dashcore.PrivateKey(wif); + let pub = pk.toAddress().toString(); - let subcommand = args.shift(); + console.info("set", defaultWifPath, pub); + await Fs.writeFile(defaultWifPath, pub, "utf8"); +} - if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) { - showHelp(); - process.exit(0); - return; - } +// TODO option to specify config dir - if (["--version", "-V", "version"].includes(subcommand)) { - showVersion(); - process.exit(0); - return; - } +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {Array} args + */ +async function listKeys({ dashApi, defaultAddr }, args) { + let wifnames = await listManagedKeynames(); - if ("generate" === subcommand) { - await generate(args.shift()); - return; + if (wifnames) { + // to print 'default staking key' message + await mustGetAddr({ defaultAddr }, args); } - let rpc = ""; - if ("http" === subcommand) { - rpc = args.shift(); - } + /** + * @type Array<{ node: String, error: Error }> + */ + let warns = []; + // console.error because console.debug goes to stdout, not stderr + debug(``); + debug(`Staking keys: (in ${keysDirRel}/)`); + debug(``); + + await wifnames.reduce(async function (promise, wifname) { + await promise; + + let wifpath = Path.join(keysDir, wifname); + let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function ( + err, + ) { + warns.push({ node: wifname, error: err }); + return ""; + }); + if (!addr) { + return; + } - if ("http" === subcommand) { - let keyfile = args.shift(); - let pub = await wifFileToAddr(keyfile); + console.info(`${addr}`); + }, Promise.resolve()); + debug(``); - // 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; + if (warns.length) { + console.warn(`Warnings:`); + warns.forEach(function (warn) { + console.warn(`${warn.node}: ${warn.error.message}`); + }); + console.warn(``); } +} - let keyfile = args.shift(); - let privKey; - if (keyfile) { - privKey = await Fs.readFile(keyfile, "utf8"); - privKey = privKey.trim(); - } else { - privKey = process.env.PRIVATE_KEY; +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {Array} args + */ +async function getAllBalances({ dashApi, defaultAddr }, args) { + let wifnames = await listManagedKeynames(); + let totals = { + key: 0, + stake: 0, + dividend: 0, + keyDash: "", + stakeDash: "", + dividendDash: "", + }; + + if (wifnames.length) { + // to print 'default staking key' message + await mustGetAddr({ defaultAddr }, args); } - 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); + + /** + * @type Array<{ node: String, error: Error }> + */ + let warns = []; + // console.error because console.debug goes to stdout, not stderr + debug(``); + debug(`Staking keys: (in ${keysDirRel}/)`); + debug(``); + console.info( + `| | 🔑 Holdings | 🪧 Stakings | 💸 Earnings |`, + ); + console.info( + `| ---------------------------------: | ------------: | ------------: | ------------: |`, + ); + if (!wifnames.length) { + console.info(` (none)`); } + await wifnames.reduce(async function (promise, wifname) { + await promise; + + let wifpath = Path.join(keysDir, wifname); + let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function ( + err, + ) { + warns.push({ node: wifname, error: err }); + return ""; + }); + if (!addr) { + return; + } - 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(wif); + let pub = pk.toAddress().toString(); + if (`${pub}.wif` !== wifname) { + // sanity check + warns.push({ + node: wifname, + error: new Error( + `computed pubkey '${pub}' of WIF does not match filename '${wifname}'`, + ), + }); + return; + } + */ - let pk = new Dashcore.PrivateKey(privKey); - let pub = pk.toPublicKey().toAddress().toString(); + process.stdout.write(`| ${addr} |`); - // 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 balanceInfo = await dashApi.getInstantBalance(addr); + let balanceDASH = toDASH(balanceInfo.balanceSat); + + let crowdNodeBalance = await CrowdNode.http.GetBalance(addr); + if (!crowdNodeBalance.TotalBalance) { + crowdNodeBalance.TotalBalance = 0; + crowdNodeBalance.TotalDividend = 0; } - } - */ + let crowdNodeDuffNum = toDuff(crowdNodeBalance.TotalBalance); + let crowdNodeDASH = toDASH(crowdNodeDuffNum); - 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: "❌", - }; + let crowdNodeDivNum = toDuff(crowdNodeBalance.TotalDividend); + let crowdNodeDivDASH = toDASH(crowdNodeDivNum); + process.stdout.write( + ` ${balanceDASH} | ${crowdNodeDASH} | ${crowdNodeDivDASH} |`, + ); - if ("balance" === subcommand) { - await balance(args, state); - process.exit(0); - return; - } + totals.key += balanceInfo.balanceSat; + totals.dividend += crowdNodeBalance.TotalDividend; + totals.stake += crowdNodeBalance.TotalBalance; - // helper for debugging - if ("transfer" === subcommand) { - await transfer(args, state); - return; + console.info(); + }, Promise.resolve()); + console.info( + `| | | | |`, + ); + let total = `| Totals`; + totals.keyDash = toDASH(toDuff(totals.key.toString())); + totals.stakeDash = toDASH(toDuff(totals.stake.toString())); + totals.dividendDash = toDASH(toDuff(totals.dividend.toString())); + console.info( + `${total} | ${totals.stakeDash} | ${totals.stakeDash} | ${totals.dividendDash} |`, + ); + debug(``); + + if (warns.length) { + console.warn(`Warnings:`); + warns.forEach(function (warn) { + console.warn(`${warn.node}: ${warn.error.message}`); + }); + console.warn(``); } +} - 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}`); +/** + * @param {String} name - ex: Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.wif.enc + */ +function isNamedLikeKey(name) { + // TODO distinguish with .enc extension? + let hasGoodLength = 34 + 4 === name.length || 34 + 4 + 4 === name.length; + let knownExt = name.endsWith(".wif") || name.endsWith(".wif.enc"); + let isTmp = name.startsWith(".") || name.startsWith("_"); + return hasGoodLength && knownExt && !isTmp; +} - state.status = await CrowdNode.status(pub, state.hotwallet); - if (state.status?.signup) { - state.signup = "✅"; +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.addr + * @param {String} opts.filepath + * @param {String} opts.insightBaseUrl + * @param {Array} args + */ +async function removeKey({ addr, dashApi, filepath, insightBaseUrl }, args) { + let balanceInfo = await dashApi.getInstantBalance(addr); + + let balanceDash = toDash(balanceInfo.balanceSat); + if (balanceInfo.balanceSat) { + console.error(``); + console.error(`Error: ${addr}`); + console.error( + ` still has a balance of ${balanceInfo.balanceSat} (Đ${balanceDash})`, + ); + console.error(` (transfer to another address before deleting)`); + console.error(``); + process.exit(1); + return; } - if (state.status?.accept) { - state.accept = "✅"; + + await initCrowdNode(insightBaseUrl); + let crowdNodeBalance = await CrowdNode.http.GetBalance(addr); + if (!crowdNodeBalance) { + // may be janky if not registered + crowdNodeBalance = {}; } - if (state.status?.deposit) { - state.deposit = "✅"; + if (!crowdNodeBalance.TotalBalance) { + crowdNodeBalance.TotalBalance = 0; } - - if ("status" === subcommand) { - await status(args, state); + let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance); + if (crowdNodeBalance.TotalBalance) { + console.error(``); + console.error(`Error: ${addr}`); + console.error( + ` still staking ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash}) on CrowdNode`, + ); + console.error( + ` (withdrawal 100.0 and transfer to another address before deleting)`, + ); + console.error(``); + process.exit(1); return; } - if ("signup" === subcommand) { - await signup(args, state); - return; + let wifname = await findWif(addr); + let fullpath = Path.join(keysDir, wifname); + let wif = await maybeReadKeyPaths(filepath, { wif: true }); + + await Fs.unlink(fullpath).catch(function (err) { + console.error(`could not remove ${filepath}: ${err.message}`); + process.exit(1); + }); + + let wifnames = await listManagedKeynames(); + console.info(``); + console.info(`No balances found. Removing ${filepath}.`); + console.info(``); + console.info(`Backup (just in case):`); + console.info(` ${wif}`); + console.info(``); + if (!wifnames.length) { + console.info(`No keys left.`); + console.info(``); + } else { + let newAddr = wifnames[0]; + debug(`Selected ${newAddr} as new default staking key.`); + await Fs.writeFile(defaultWifPath, addr.replace(".wif", ""), "utf8"); + console.info(``); } +} - if ("accept" === subcommand) { - await accept(args, state); - return; +/** + * @param {String} pre + */ +async function findWif(pre) { + if (!pre) { + return ""; } - if ("deposit" === subcommand) { - await deposit(args, state); - return; + let names = await listManagedKeynames(); + names = names.filter(function (name) { + return name.startsWith(pre); + }); + + if (!names.length) { + return ""; } - if ("withdrawal" === subcommand) { - await withdrawal(args, state); - return; + if (names.length > 1) { + console.error(`'${pre}' is ambiguous:`, names.join(", ")); + process.exit(1); + return ""; } - console.error(`Unrecognized subcommand ${subcommand}`); - console.error(); - showHelp(); - process.exit(1); + return names[0]; } -// Subcommands - -async function generate(name) { - let pk = new Dashcore.PrivateKey(); +async function listManagedKeynames() { + let nodes = await Fs.readdir(keysDir); - let pub = pk.toAddress().toString(); - let wif = pk.toWIF(); + return nodes.filter(isNamedLikeKey); +} - let filepath = `./${pub}.wif`; - let note = ""; - if (name) { - filepath = name; - note = `\n(for pubkey address ${pub})`; +/** + * @param {Object} opts + * @param {String} opts.defaultAddr + * @param {String} opts.insightBaseUrl + * @param {Array} args + */ +async function loadAddr({ defaultAddr, insightBaseUrl }, args) { + let [addr] = await mustGetAddr({ defaultAddr }, args); + + let desiredAmountDash = parseFloat(args.shift() || "0"); + let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS); + + let effectiveDuff = desiredAmountDuff; + let effectiveDash = ""; + if (!effectiveDuff) { + effectiveDuff = CrowdNode.stakeMinimum + signupTotal + feeEstimate; + effectiveDuff = roundDuff(effectiveDuff, 3); + effectiveDash = toDash(effectiveDuff); } - 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 plainLoadAddr({ addr, effectiveDash, effectiveDuff, insightBaseUrl }); - 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); + return; +} + +/** + * 1000 to Round to the nearest mDash + * ex: 0.50238108 => 0.50300000 + * @param {Number} effectiveDuff + * @param {Number} numDigits + */ +function roundDuff(effectiveDuff, numDigits) { + let n = Math.pow(10, numDigits); + let effectiveDash = toDash(effectiveDuff); + effectiveDuff = toDuff( + (Math.ceil(parseFloat(effectiveDash) * n) / n).toString(), + ); + return effectiveDuff; +} + +/** + * @param {Object} opts + * @param {String} opts.addr + * @param {String} opts.effectiveDash + * @param {Number} opts.effectiveDuff + * @param {String} opts.insightBaseUrl + */ +async function plainLoadAddr({ + addr, + effectiveDash, + effectiveDuff, + insightBaseUrl, +}) { + console.info(``); + showQr(addr, effectiveDuff); + console.info(``); + console.info( + `Use the QR Code above to load ${effectiveDuff} (Đ${effectiveDash}) onto your staking key.`, + ); + console.info(``); + console.info(`(waiting...)`); + console.info(``); + let payment = await Ws.waitForVout(insightBaseUrl, addr, 0); + console.info(`Received ${payment.satoshis}`); } -async function balance(args, state) { - console.info(state.balanceInfo); - process.exit(0); +/** + * @param {Object} opts + * @param {String} opts.defaultAddr + * @param {any} opts.dashApi - TODO + * @param {Array} args + */ +async function getBalance({ dashApi, defaultAddr }, args) { + let [addr] = await mustGetAddr({ defaultAddr }, args); + await checkBalance({ addr, dashApi }); + //let balanceInfo = await checkBalance({ addr, dashApi }); + //console.info(balanceInfo); return; } -async function transfer(args, state) { - let newAddr = await wifFileToAddr(process.argv[4]); - let amount = parseInt(process.argv[5] || 0, 10); +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {Boolean} opts.forceConfirm + * @param {String} opts.insightBaseUrl + * @param {any} opts.insightApi + * @param {Array} args + */ +// ex: node ./bin/crowdnode.js transfer ./priv.wif 'pub' 0.01 +async function transferBalance( + { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi }, + args, +) { + let wif = await mustGetWif({ defaultAddr }, args); + + let keyname = args.shift() || ""; + let newAddr = await wifFileToAddr(keyname); + let dashAmount = parseFloat(args.shift() || "0"); + let duffAmount = Math.round(dashAmount * DUFFS); let tx; - if (amount) { - tx = await state.dashApi.createPayment(state.privKey, newAddr, amount); + if (duffAmount) { + tx = await dashApi.createPayment(wif, newAddr, duffAmount); } else { - tx = await state.dashApi.createBalanceTransfer(state.privKey, newAddr); + tx = await dashApi.createBalanceTransfer(wif, newAddr); } - if (amount) { - console.info(`Transferring ${amount} to ${newAddr}...`); + if (duffAmount) { + let dashAmountStr = toDash(duffAmount); + console.info( + `Transferring ${duffAmount} (Đ${dashAmountStr}) to ${newAddr}...`, + ); } else { console.info(`Transferring balance to ${newAddr}...`); } - await state.insightApi.instantSend(tx); + await 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) { + if (forceConfirm) { console.error(`(using --unconfirmed may lead to rejected double spends)`); } process.exit(1); }, 30 * 1000); - await Ws.waitForVout(state.insightBaseUrl, newAddr, 0); + await Ws.waitForVout(insightBaseUrl, newAddr, 0); console.info(`Accepted!`); - process.exit(0); return; } -async function status(args, state) { +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {String} opts.insightBaseUrl + * @param {Array} args + */ +async function getStatus({ dashApi, defaultAddr, insightBaseUrl }, args) { + let [addr] = await mustGetAddr({ defaultAddr }, args); + await initCrowdNode(insightBaseUrl); + let hotwallet = CrowdNode.main.hotwallet; + let state = await getCrowdNodeStatus({ addr, hotwallet }); + console.info(); - console.info(`API Actions Complete for ${state.pub}:`); + console.info(`API Actions Complete for ${addr}:`); console.info(` ${state.signup} SignUpForApi`); console.info(` ${state.accept} AcceptTerms`); console.info(` ${state.deposit} DepositReceived`); console.info(); - process.exit(0); + let crowdNodeBalance = await CrowdNode.http.GetBalance(addr); + // may be unregistered / undefined + /* + * { + * '@odata.context': 'https://app.crowdnode.io/odata/$metadata#Edm.String', + * value: 'Address not found.' + * } + */ + if (!crowdNodeBalance.TotalBalance) { + crowdNodeBalance.TotalBalance = 0; + } + let crowdNodeDuff = toDuff(crowdNodeBalance.TotalBalance); + console.info( + `CrowdNode Stake: ${crowdNodeDuff} (Đ${crowdNodeBalance.TotalBalance})`, + ); + console.info(); return; } -async function signup(args, state) { +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {String} opts.insightBaseUrl + * @param {Array} args + */ +async function sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args) { + let [addr, name] = await mustGetAddr({ defaultAddr }, args); + await initCrowdNode(insightBaseUrl); + let hotwallet = CrowdNode.main.hotwallet; + let state = await getCrowdNodeStatus({ addr, hotwallet }); + let balanceInfo = await checkBalance({ addr, dashApi }); + if (state.status?.signup) { - console.info( - `${state.pub} is already signed up. Here's the account status:`, - ); + console.info(`${addr} 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; + let hasEnough = balanceInfo.balanceSat > signupOnly + feeEstimate; if (!hasEnough) { - await collectSignupFees(state.insightBaseUrl, state.pub); + await collectSignupFees(insightBaseUrl, addr); } + + let wif = await maybeReadKeyPaths(name, { wif: true }); + console.info("Requesting account..."); - await CrowdNode.signup(state.privKey, state.hotwallet); - state.signup = "✅"; - console.info(` ${signup} SignUpForApi`); - console.info(` ${accept} AcceptTerms`); - process.exit(0); + await CrowdNode.signup(wif, hotwallet); + state.signup = DONE; + console.info(` ${state.signup} SignUpForApi`); return; } -async function accept(args, state) { +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {String} opts.insightBaseUrl + * @param {Array} args + */ +async function acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args) { + let [addr, name] = await mustGetAddr({ defaultAddr }, args); + + await initCrowdNode(insightBaseUrl); + let hotwallet = CrowdNode.main.hotwallet; + let state = await getCrowdNodeStatus({ addr, hotwallet }); + let balanceInfo = await dashApi.getInstantBalance(addr); + + if (!state.status?.signup) { + console.info(`${addr} is not signed up yet. Here's the account status:`); + console.info(` ${state.signup} SignUpForApi`); + console.info(` ${state.accept} AcceptTerms`); + process.exit(1); + return; + } + if (state.status?.accept) { - console.info( - `${state.pub} is already signed up. Here's the account status:`, - ); + console.info(`${addr} 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; + let hasEnough = balanceInfo.balanceSat > acceptOnly + feeEstimate; if (!hasEnough) { - await collectSignupFees(state.insightBaseUrl, state.pub); + await collectSignupFees(insightBaseUrl, addr); } + + let wif = await maybeReadKeyPaths(name, { wif: true }); + console.info("Accepting terms..."); - await CrowdNode.accept(state.privKey, state.hotwallet); - state.accept = "✅"; - console.info(` ${state.signup} SignUpForApi`); + await CrowdNode.accept(wif, hotwallet); + state.accept = DONE; console.info(` ${state.accept} AcceptTerms`); - console.info(` ${state.deposit} DepositReceived`); - process.exit(0); return; } -async function deposit(args, state) { +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {String} opts.insightBaseUrl + * @param {Boolean} opts.noReserve + * @param {Array} args + */ +async function depositDash( + { dashApi, defaultAddr, insightBaseUrl, noReserve }, + args, +) { + let [addr, name] = await mustGetAddr({ defaultAddr }, args); + await initCrowdNode(insightBaseUrl); + let hotwallet = CrowdNode.main.hotwallet; + let state = await getCrowdNodeStatus({ addr, hotwallet }); + let balanceInfo = await dashApi.getInstantBalance(addr); + if (!state.status?.accept) { - console.error(`no account for address ${state.pub}`); + console.error(`no account for address ${addr}`); 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) { + let reserveDash = toDash(reserve); + if (!noReserve) { console.info( - `reserving ${reserve} (${reserveDash}) for withdrawals (--no-reserve to disable)`, + `reserving ${reserve} (Đ${reserveDash}) for withdrawals (--no-reserve to disable)`, ); } else { reserve = 0; @@ -449,96 +1887,136 @@ async function deposit(args, state) { // 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; + let desiredAmountDash = parseFloat(args.shift() || "0"); + let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS); + let effectiveAmount = desiredAmountDuff; if (!effectiveAmount) { - effectiveAmount = state.balanceInfo.balanceSat - reserve; + effectiveAmount = balanceInfo.balanceSat - reserve; } let needed = Math.max(reserve * 2, effectiveAmount + reserve); - if (state.balanceInfo.balanceSat < needed) { + if (balanceInfo.balanceSat < needed) { let ask = 0; - if (desiredAmount) { - ask = desiredAmount + reserve + -state.balanceInfo.balanceSat; + if (desiredAmountDuff) { + ask = desiredAmountDuff + reserve + -balanceInfo.balanceSat; } - await collectDeposit(state.insightBaseUrl, state.pub, ask); - state.balanceInfo = await state.dashApi.getInstantBalance(state.pub); - if (state.balanceInfo.balanceSat < needed) { + await collectDeposit(insightBaseUrl, addr, ask); + balanceInfo = await dashApi.getInstantBalance(addr); + if (balanceInfo.balanceSat < needed) { + let balanceDash = toDash(balanceInfo.balanceSat); console.error( - `Balance is still too small: ${state.balanceInfo.balanceSat}`, + `Balance is still too small: ${balanceInfo.balanceSat} (Đ${balanceDash})`, ); process.exit(1); return; } } - if (!desiredAmount) { - effectiveAmount = state.balanceInfo.balanceSat - reserve; + if (!desiredAmountDuff) { + effectiveAmount = 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 = "✅"; + let effectiveDash = toDash(effectiveAmount); + console.info( + `Initiating deposit of ${effectiveAmount} (Đ${effectiveDash})...`, + ); + + let wif = await maybeReadKeyPaths(name, { wif: true }); + + await CrowdNode.deposit(wif, hotwallet, effectiveAmount); + state.deposit = DONE; console.info(` ${state.deposit} DepositReceived`); - process.exit(0); return; } -async function withdrawal(args, state) { +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {String} opts.insightBaseUrl + * @param {Array} args + */ +async function withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args) { + let [addr] = await mustGetAddr({ defaultAddr }, args); + await initCrowdNode(insightBaseUrl); + let hotwallet = CrowdNode.main.hotwallet; + let state = await getCrowdNodeStatus({ addr, hotwallet }); + if (!state.status?.accept) { - console.error(`no account for address ${state.pub}`); + console.error(`no account for address ${addr}`); process.exit(1); return; } - let amount = parseInt(args.shift() || 1000, 10); + let percentStr = args.shift() || "100.0"; + // pass: .1 0.1, 1, 1.0, 10, 10.0, 100, 100.0 + // fail: 1000, 10.00 + if (!/^1?\d?\d?(\.\d)?$/.test(percentStr)) { + console.error("Error: withdrawal percent must be between 0.1 and 100.0"); + process.exit(1); + } + let percent = parseFloat(percentStr); + + let permil = Math.round(percent * 10); + if (permil <= 0 || permil > 1000) { + console.error("Error: withdrawal percent must be between 0.1 and 100.0"); + process.exit(1); + } + + let realPercentStr = (permil / 10).toFixed(1); + console.info(`Initiating withdrawal of ${realPercentStr}...`); - console.info("Initiating withdrawal..."); - let paid = await CrowdNode.withdrawal(state.privKey, state.hotwallet, amount); + let wifname = await findWif(addr); + let filepath = Path.join(keysDir, wifname); + let wif = await maybeReadKeyFile(filepath); + let paid = await CrowdNode.withdrawal(wif, hotwallet, permil); //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) { +/** + * Convert prefix, addr, keyname, or filepath to pub addr + * @param {String} name + * @throws + */ +async function wifFileToAddr(name) { + if (34 === name.length) { // actually payment addr - return privKey; + return name; } - if (52 === privKey.length) { - let pk = new Dashcore.PrivateKey(privKey); - let pub = pk.toPublicKey().toAddress().toString(); - return pub; + let privKey = ""; + + let wifname = await findWif(name); + if (wifname) { + let filepath = Path.join(keysDir, wifname); + privKey = await maybeReadKeyFile(filepath); + } + if (!privKey) { + privKey = await maybeReadKeyFile(name); + } + if (!privKey) { + throw new Error("bad file path or address"); } - throw new Error("bad file path or address"); + let pk = new Dashcore.PrivateKey(privKey); + let pub = pk.toPublicKey().toAddress().toString(); + return pub; } -async function collectSignupFees(insightBaseUrl, pub) { - showQr(pub); +/** + * @param {String} insightBaseUrl + * @param {String} addr + */ +async function collectSignupFees(insightBaseUrl, addr) { + console.info(``); + showQr(addr); - let signupTotalDash = (signupTotal / DUFFS).toFixed(8); - let signupMsg = `Please send >= ${signupTotal} (${signupTotalDash}) to Sign Up to CrowdNode`; + let signupTotalDash = toDash(signupTotal); + 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); @@ -551,17 +2029,23 @@ async function collectSignupFees(insightBaseUrl, pub) { console.info(""); console.info("(waiting...)"); console.info(""); - let payment = await Ws.waitForVout(insightBaseUrl, pub, 0); + let payment = await Ws.waitForVout(insightBaseUrl, addr, 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}`; +/** + * @param {String} insightBaseUrl + * @param {String} addr + * @param {Number} duffAmount + */ +async function collectDeposit(insightBaseUrl, addr, duffAmount) { + console.info(``); + showQr(addr, duffAmount); + + let depositMsg = `Please send what you wish to deposit to ${addr}`; + if (duffAmount) { + let dashAmount = toDash(duffAmount); + depositMsg = `Please deposit ${duffAmount} (Đ${dashAmount}) to ${addr}`; } let msgPad = Math.ceil((qrWidth - depositMsg.length) / 2); @@ -574,10 +2058,43 @@ async function collectDeposit(insightBaseUrl, pub, amount) { console.info(""); console.info("(waiting...)"); console.info(""); - let payment = await Ws.waitForVout(insightBaseUrl, pub, 0); + let payment = await Ws.waitForVout(insightBaseUrl, addr, 0); console.info(`Received ${payment.satoshis}`); } +/** + * @param {Error & { code: String }} err + * @throws + */ +function emptyStringOnErrEnoent(err) { + if ("ENOENT" !== err.code) { + throw err; + } + return ""; +} + +/** + * @param {Number} duffs - ex: 00000000 + */ +function toDash(duffs) { + return (duffs / DUFFS).toFixed(8); +} + +/** + * @param {Number} duffs - ex: 00000000 + */ +function toDASH(duffs) { + let dash = (duffs / DUFFS).toFixed(8); + return `Đ` + dash.padStart(12, " "); +} + +/** + * @param {String} dash - ex: 0.00000000 + */ +function toDuff(dash) { + return Math.round(parseFloat(dash) * DUFFS); +} + // Run main().catch(function (err) {