From f36ed50398c3eb09c6ffb5ceac7d14eb68a37bcd Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 21 Jun 2022 01:28:42 -0600 Subject: [PATCH] feat: fully managed, encrypted staking keys --- bin/_cipher.js | 158 +++++ bin/_prompt.js | 63 ++ bin/crowdnode.js | 1490 ++++++++++++++++++++++++++++++++++++++-------- cli/README.md | 21 +- tsconfig.json | 2 +- 5 files changed, 1481 insertions(+), 253 deletions(-) create mode 100644 bin/_cipher.js create mode 100644 bin/_prompt.js diff --git a/bin/_cipher.js b/bin/_cipher.js new file mode 100644 index 0000000..56c7093 --- /dev/null +++ b/bin/_cipher.js @@ -0,0 +1,158 @@ +"use strict"; + +let Crypto = require("crypto"); + +let Cipher = module.exports; + +const ALG = "aes-128-cbc"; +const IV_SIZE = 16; + +/** + * @param {String} passphrase - what the human entered + * @param {String} shadow - encrypted, hashed, key-expanded passphrase + */ +Cipher.checkPassphrase = async function (passphrase, shadow) { + let key128 = await Cipher.deriveKey(passphrase); + let cipher = Cipher.create(key128); + + let plainShadow; + try { + plainShadow = cipher.decrypt(shadow); + } catch (e) { + //@ts-ignore + let msg = e.message; + if (!msg.includes("decrypt")) { + throw e; + } + return false; + } + + let untrustedShadow = Crypto.createHash("sha512") + .update(key128) + .digest("base64"); + return Cipher.secureCompare(plainShadow, untrustedShadow); +}; + +/** + * @param {String} passphrase - what the human entered + */ +Cipher.shadowPassphrase = async function (passphrase) { + let key128 = await Cipher.deriveKey(passphrase); + let plainShadow = Crypto.createHash("sha512").update(key128).digest("base64"); + let cipher = Cipher.create(key128); + let shadow = cipher.encrypt(plainShadow); + + return shadow; +}; + +/** + * @param {String} passphrase + */ +Cipher.deriveKey = async function (passphrase) { + // See https://crypto.stackexchange.com/a/6557 + // and https://nodejs.org/api/crypto.html#cryptohkdfdigest-ikm-salt-info-keylen-callback + const DIGEST = "sha512"; + const SALT = Buffer.from("crowdnode-cli", "utf8"); + // 'info' is a string describing a sub-context + const INFO = Buffer.from("staking-keys", "utf8"); + const SIZE = 16; + + let ikm = Buffer.from(passphrase, "utf8"); + let key128 = await new Promise(function (resolve, reject) { + //@ts-ignore + Crypto.hkdf(DIGEST, ikm, SALT, INFO, SIZE, function (err, key128) { + if (err) { + reject(err); + return; + } + resolve(Buffer.from(key128)); + }); + }); + + return key128; +}; + +/** + * @param {String} shadow + * @param {Buffer} key128 + */ +Cipher.checkShadow = function (shadow, key128) { + let untrustedShadow = Crypto.createHash("sha512") + .update(key128) + .digest("base64"); + return Cipher.secureCompare(shadow, untrustedShadow); +}; + +/** + * @param {String} a + * @param {String} b + */ +Cipher.secureCompare = function (a, b) { + if (!a && !b) { + throw new Error("[secure compare] reference string should not be empty"); + } + + if (a.length !== b.length) { + return false; + } + + return Crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); +}; + +/** + * @param {Buffer} key128 + */ +Cipher.create = function (key128) { + //let sharedSecret = Buffer.from(key128, "base64"); + + let cipher = {}; + + /** + * @param {String} plaintext + */ + cipher.encrypt = function (plaintext) { + let initializationVector = Crypto.randomBytes(IV_SIZE); // IV is always 16-bytes + let encrypted = ""; + + let _cipher = Crypto.createCipheriv(ALG, key128, initializationVector); + encrypted += _cipher.update(plaintext, "utf8", "base64"); + encrypted += _cipher.final("base64"); + + return ( + toWeb64(initializationVector.toString("base64")) + + ":" + + toWeb64(encrypted) + + ":" + + // as a backup + toWeb64(initializationVector.toString("base64")) + ); + }; + + /** + * @param {String} parts + */ + cipher.decrypt = function (parts) { + let [initializationVector, encrypted, initializationVectorBak] = + parts.split(":"); + let plaintext = ""; + if (initializationVector !== initializationVectorBak) { + console.error("corrupt (but possibly recoverable) initialization vector"); + } + + let iv = Buffer.from(initializationVector, "base64"); + let _cipher = Crypto.createDecipheriv(ALG, key128, iv); + plaintext += _cipher.update(encrypted, "base64", "utf8"); + plaintext += _cipher.final("utf8"); + + return plaintext; + }; + + return cipher; +}; + +/** + * @param {String} x + */ +function toWeb64(x) { + return x.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} diff --git a/bin/_prompt.js b/bin/_prompt.js new file mode 100644 index 0000000..b169ac5 --- /dev/null +++ b/bin/_prompt.js @@ -0,0 +1,63 @@ +"use strict"; + +let Prompt = module.exports; + +/** + * @param {String} query + * @param {Object} [options] + * @param {Array} [options.choices] + * @param {Boolean} [options.mask] + */ +Prompt.prompt = async function (query, options) { + let Readline = require("readline"); + + let completer; + if (options?.choices) { + /** + * @param {String} line + */ + completer = function (line) { + let completions = options.choices || []; + let hits = completions.filter(function (c) { + return c.startsWith(line); + }); + if (!hits.length) { + hits = completions; + } + return [hits, line]; + }; + } + + let rl = Readline.createInterface({ + input: process.stdin, + output: process.stdout, + completer, + }); + + if (options?.mask) { + //@ts-ignore + rl.input.on("keypress", function (_char, _modifiers) { + // _char = "e" + // _modifiers = { sequence: 'e', name: 'e', ctrl: false, meta: false, shift: false } + let len = rl.line.length; + // place cursor at the beginning of the prompt + //@ts-ignore + Readline.moveCursor(rl.output, -len, 0); + // clear right of the cursor / prompt + //@ts-ignore + Readline.clearLine(rl.output, 1); + // mask with "*" + //@ts-ignore + rl.output.write("*".repeat(len)); + }); + } + + let answer = await new Promise(function (resolve) { + return rl.question(query ?? "", resolve); + }); + + // TODO what if we need control over closing? + // ex: Promise.race([getPrompt, getFsEvent, getSocketEvent]); + rl.close(); + return answer; +}; diff --git a/bin/crowdnode.js b/bin/crowdnode.js index ddeafe2..2cf8cdf 100755 --- a/bin/crowdnode.js +++ b/bin/crowdnode.js @@ -5,13 +5,19 @@ 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"); @@ -31,19 +37,12 @@ let signupFees = signupOnly + acceptOnly; let feeEstimate = 500; let signupTotal = signupFees + 2 * feeEstimate; -function showQr(signupAddr, duffs = 0) { - let signupUri = `dash://${signupAddr}`; - if (duffs) { - signupUri += `?amount=${duffs}`; - } - - 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 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 showVersion() { console.info(`${pkg.name} v${pkg.version} - ${pkg.description}`); @@ -55,24 +54,36 @@ function showHelp() { 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 status [keyfile-or-addr]"); + console.info(" crowdnode signup [keyfile-or-addr]"); + console.info(" crowdnode accept [keyfile-or-addr]"); console.info( - " crowdnode deposit ./privkey.wif [dash-amount] [--no-reserve]", + " crowdnode deposit [keyfile-or-addr] [dash-amount] [--no-reserve]", ); console.info( - " crowdnode withdrawal ./privkey.wif # 1.0-100.0 (steps by 0.1)", + " 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 load [./privkey.wif]"); - console.info(" crowdnode balance ./privkey.wif"); + console.info(" crowdnode balance [keyfile-or-addr]"); // addr + console.info(" crowdnode load [keyfile-or-addr] [dash-amount]"); // addr console.info( - " crowdnode transfer ./source.wif [dash-amount]", - ); + " crowdnode transfer [dash-amount]", + ); // custom + console.info(""); + + console.info("Key Management & Encryption:"); + console.info(" crowdnode generate [./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:"); @@ -93,15 +104,12 @@ function showHelp() { console.info(""); } -function removeItem(arr, item) { - let index = arr.indexOf(item); - if (index >= 0) { - return arr.splice(index, 1)[0]; - } - return null; -} +let cmds = {}; async function main() { + /*jshint maxcomplexity:40 */ + /*jshint maxstatements:500 */ + // Usage: // crowdnode [flags] [options] // Example: @@ -127,44 +135,113 @@ async function main() { return; } - if ("generate" === subcommand) { - await generate(args.shift()); - 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 ("load" === subcommand) { - await load( - { - dashApi: dashApi, - insightBaseUrl: insightBaseUrl, - insightApi: insightApi, - }, + if ("list" === subcommand) { + await listKeys({ dashApi }, args); + return; + } + + if ("generate" === subcommand) { + await generateKey({ defaultKey: defaultAddr }, args); + return; + } + + if ("passphrase" === subcommand) { + await setPassphrase({}, args); + return; + } + + if ("import" === subcommand) { + importKey(null, args); + return; + } + + if ("encrypt" === subcommand) { + let addr = args.shift() || ""; + if (!addr) { + encryptAll(null); + 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"); + } + encryptAll([key]); + return; + } + + if ("decrypt" === subcommand) { + let addr = args.shift() || ""; + if (!addr) { + decryptAll(null); + 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"); + } + decryptAll([key]); + return; + } + + // use or select or default... ? + if ("use" === subcommand) { + await setDefault(null, args); + return; + } + + // helper for debugging + if ("transfer" === subcommand) { + await transferBalance( + { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi }, args, ); return; } - process.stdout.write("Checking CrowdNode API... "); - await CrowdNode.init({ - baseUrl: "https://app.crowdnode.io", - insightBaseUrl, - insightApi: insightApi, - }); - console.info(`hotwallet is ${CrowdNode.main.hotwallet}`); - let rpc = ""; if ("http" === subcommand) { - rpc = args.shift(); - let keyfile = args.shift(); - let pub = await wifFileToAddr(keyfile); + rpc = args.shift() || ""; + if (!rpc) { + showHelp(); + process.exit(1); + return; + } + let [addr] = await mustGetAddr({ defaultAddr }, args); + + await initCrowdNode(insightBaseUrl); // ex: http (, ...) - args.unshift(pub); + args.unshift(addr); let hasRpc = rpc in CrowdNode.http; if (!hasRpc) { console.error(`Unrecognized rpc command ${rpc}`); @@ -172,7 +249,10 @@ async function main() { 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 { @@ -181,30 +261,140 @@ async function main() { 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 ("load" === subcommand) { + await loadAddr({ defaultAddr, insightBaseUrl }, args); + return; } - 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`, + + // keeping rm for backwards compat + if ("rm" === subcommand || "delete" === subcommand) { + await initCrowdNode(insightBaseUrl); + await removeKey({ defaultAddr, dashApi, insightBaseUrl }, args); + return; + } + + if ("balance" === subcommand) { + await getBalance({ dashApi, defaultAddr }, args); + process.exit(0); + return; + } + + if ("status" === subcommand) { + await getStatus({ dashApi, defaultAddr, insightBaseUrl }, args); + return; + } + + if ("signup" === subcommand) { + await sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args); + return; + } + + if ("accept" === subcommand) { + await acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args); + return; + } + + if ("deposit" === subcommand) { + await depositDash( + { dashApi, defaultAddr, insightBaseUrl, noReserve }, + args, ); - console.error(); - process.exit(1); + return; } - let pk = new Dashcore.PrivateKey(privKey); - let pub = pk.toPublicKey().toAddress().toString(); + if ("withdrawal" === subcommand) { + await withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args); + return; + } + + console.error(`Unrecognized subcommand ${subcommand}`); + console.error(); + showHelp(); + process.exit(1); +} + +/** + * @param {String} insightBaseUrl + */ +async function initCrowdNode(insightBaseUrl) { + 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 dashUri = `dash://${addr}`; + if (duffs) { + dashUri += `?amount=${duffs}`; + } + + let dashQr = Qr.ascii(dashUri, { indent: 4 }); + let addrPad = 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: "❌", + accept: "❌", + deposit: "❌", + status: { + signup: 0, + accept: 0, + deposit: 0, + }, + }; + + //@ts-ignore - TODO why warnings? + state.status = await CrowdNode.status(addr, hotwallet); + if (state.status?.signup) { + state.signup = "✅"; + } + if (state.status?.accept) { + state.accept = "✅"; + } + if (state.status?.deposit) { + state.deposit = "✅"; + } + 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) process.stdout.write("Checking balance... "); - let balanceInfo = await dashApi.getInstantBalance(pub); + let balanceInfo = await dashApi.getInstantBalance(addr); let balanceDash = toDash(balanceInfo.balanceSat); console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`); /* @@ -222,152 +412,821 @@ async function main() { } } */ + return balanceInfo; +} - let state = { - balanceInfo: balanceInfo, - dashApi: dashApi, - forceConfirm: forceConfirm, - hotwallet: CrowdNode.main.hotwallet, - insightBaseUrl: insightBaseUrl, - insightApi: insightApi, - noReserve: noReserve, - privKey: privKey, - pub: pub, - - // status - status: { - signup: 0, - accept: 0, - deposit: 0, - }, - signup: "❌", - accept: "❌", - deposit: "❌", - }; +/** + * @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]; + } - if ("balance" === subcommand) { - await balance(args, state); - process.exit(0); - return; + 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]; } - // helper for debugging - if ("transfer" === subcommand) { - await transfer(args, state); - return; + let isNum = !isNaN(parseFloat(name)); + if (isNum) { + args.unshift(name); + name = ""; } - state.status = await CrowdNode.status(pub, state.hotwallet); - if (state.status?.signup) { - state.signup = "✅"; + if (name) { + console.error(); + console.error(`could not read '${name}' in ./ or match in ${keysDirRel}/.`); + console.error(); + process.exit(1); + return ["", name]; } - if (state.status?.accept) { - state.accept = "✅"; + + 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; } - if (state.status?.deposit) { - state.deposit = "✅"; + + let isNum = !isNaN(parseFloat(name)); + if (isNum) { + args.unshift(name); + name = ""; } - if ("status" === subcommand) { - await status(args, state); - return; + if (name) { + console.error(); + console.error( + `'${name}' does not match a staking key in ./ or ${keysDirRel}/`, + ); + console.error(); + process.exit(1); + return ""; } - if ("signup" === subcommand) { - await signup(args, state); - 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 ("accept" === subcommand) { - await accept(args, state); - return; + if (false === opts.wif) { + return wifname.slice(0, -".wif".length); } - if ("deposit" === subcommand) { - await deposit(args, state); - return; + let filepath = Path.join(keysDir, wifname); + privKey = await maybeReadKeyFile(filepath); + if (!privKey) { + // local in ./ + privKey = await maybeReadKeyFile(name); } - if ("withdrawal" === subcommand) { - await withdrawal(args, state); - return; + 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) { + console.info(`selected default staking key ${defaultAddr}`); + return defaultWif; } - console.error(`Unrecognized subcommand ${subcommand}`); console.error(); - showHelp(); + 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 -async function generate(name) { +/** + * @param {Object} psuedoState + * @param {String} psuedoState.defaultKey - addr name of default key + * @param {Array} args + */ +async function generateKey({ defaultKey }, args) { + let name = args.shift(); + //@ts-ignore - TODO submit JSDoc PR for Dashcore let pk = new Dashcore.PrivateKey(); - let pub = pk.toAddress().toString(); - let wif = pk.toWIF(); + let addr = pk.toAddress().toString(); + let plainWif = pk.toWIF(); + + let wif = await maybeEncrypt(plainWif); - let filepath = `./${pub}.wif`; + 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 ${pub})`; + note = `\n(for pubkey address ${addr})`; + let err = await Fs.access(filepath).catch(Object); + if (!err) { + console.info(`'${filepath}' already exists (will not overwrite)`); + process.exit(0); + return; + } } - let testDash = 0.01; - let testDuff = toDuff(testDash); + await Fs.writeFile(filepath, wif, "utf8"); + if (!name && !defaultKey) { + await Fs.writeFile(defaultWifPath, addr, "utf8"); + } - let err = await Fs.access(filepath).catch(Object); - if (!err) { - // TODO show QR anyway - //wif = await Fs.readFile(filepath, 'utf8') - //showQr(pub, testDuff); - console.info(`'${filepath}' already exists (will not overwrite)`); - process.exit(0); + console.info(``); + console.info(`Generated ${filename} ${note}`); + console.info(``); + process.exit(0); + return; +} + +/** + * @param {Object} state + * @param {Boolean} [state._askPreviousPassphrase] - don't ask for passphrase again + * @param {Array} args + */ +async function setPassphrase({ _askPreviousPassphrase }, args) { + let date = getFsDateString(); + + // get the old passphrase + if (false !== _askPreviousPassphrase) { + await cmds.getPassphrase(null, []); + } + + // get the new passphrase + 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"); + } + 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(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 }); + + return newPassphrase; +} + +/** + * Import and Encrypt + * @param {Null} _ + * @param {Array} args + */ +async function importKey(_, args) { + let keypath = args.shift() || ""; + let key = await maybeReadKeyFileRaw(keypath); + if (!key?.wif) { + console.error(`no key found for '${keypath}'`); + process.exit(1); return; } - await Fs.writeFile(filepath, wif, "utf8"); - console.info(``); - console.info( - `Use the QR Code below to load a test deposit of Đ${testDash} onto your staking key.`, + 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(``); - showQr(pub, testDuff); +} + +/** + * 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(); + + console.info(`Encrypting...`); console.info(``); - console.info( - `Use the QR Code above to load a test deposit of Đ${testDash} onto your staking key.`, - ); + 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); + 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(``); - console.info(`Generated ${filepath} ${note}`); - process.exit(0); } -async function load(psuedoState, args) { - let name = args.shift(); +/** + * Decrypt ALL-the-things! + * @param {Array?} rawKeys + */ +async function decryptAll(rawKeys) { + if (!rawKeys) { + rawKeys = await readAllKeys(); + } + let date = getFsDateString(); - // TODO factor out the common bits from generate? - let pk = new Dashcore.PrivateKey(); + console.info(``); + console.info(`Decrypting...`); + console.info(``); + await rawKeys.reduce(async function (promise, key) { + await promise; - let pub = pk.toAddress().toString(); - let wif = pk.toWIF(); + 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 ✅`); + console.info(``); +} - let filepath = `./${pub}.wif`; - let note = ""; - if (name) { - filepath = name; - note = `\n(for pubkey address ${pub})`; - } +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) { - console.info(`'${filepath}' already exists (will not overwrite)`); + await Fs.rename(filepath, bakpath); + } + await Fs.rename(tmpPath, filepath); + if (!err) { + await Fs.unlink(bakpath); + } +} + +/** + * @param {Null} psuedoState + * @param {Array} args + */ +cmds.getPassphrase = async function (psuedoState, args) { + // 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(function (err) { + if ("ENOENT" === err.code) { + needsInit = true; + return; + } + throw err; + }); + + // State 1: not initialized, what does the user want? + if (needsInit) { + for (;;) { + let 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())) { + let passphrase = await setPassphrase( + { _askPreviousPassphrase: false }, + args, + ); + cmds._setPassphrase(passphrase); + return passphrase; + } + + // ask user again + if (!["no", "n"].includes(no.toLowerCase())) { + continue; + } + + // No passphrase, create empty shadow file + await Fs.writeFile(shadowPath, "", "utf8"); + return ""; + } + } + + // State 2: shadow already initialized to empty + // (user doesn't want a passphrase) + if (!shadow) { + cmds._setPassphrase(""); + return ""; + } + + // State 3: passphrase & shadow already in use + for (;;) { + let passphrase = await Prompt.prompt("Enter (current) passphrase: ", { + mask: true, + }); + passphrase = passphrase.trim(); + if (!passphrase || "q" === passphrase) { + console.error("cancel: no passphrase"); + process.exit(1); + return; + } + + let match = await Cipher.checkPassphrase(passphrase, shadow); + if (match) { + cmds._setPassphrase(passphrase); + console.info(``); + return passphrase; + } + + 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; + }; +}; + +/** + * @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) { + passphrase = await cmds.getPassphrase(null, []); + } + let key128 = await Cipher.deriveKey(passphrase); + let cipher = Cipher.create(key128); + + return cipher.decrypt(encWif); +} + +/** + * @param {String} plainWif + */ +async function maybeEncrypt(plainWif) { + let passphrase = cmds._getPassphrase(); + if (!passphrase) { + passphrase = await cmds.getPassphrase(null, []); + } + if (!passphrase) { + return plainWif; + } + + let key128 = await Cipher.deriveKey(passphrase); + let cipher = Cipher.create(key128); + return cipher.encrypt(plainWif); +} + +/** + * @param {Null} _ + * @param {Array} args + */ +async function setDefault(_, args) { + let addr = args.shift() || ""; + + let keyname = await findWif(addr); + if (!keyname) { + console.error(`no key matches '${addr}'`); + process.exit(1); + return; + } + + let filepath = Path.join(keysDir, keyname); + let wif = await maybeReadKeyFile(filepath); + let pk = new Dashcore.PrivateKey(wif); + let pub = pk.toAddress().toString(); + + console.info("set", defaultWifPath, pub); + await Fs.writeFile(defaultWifPath, pub, "utf8"); +} + +// TODO option to specify config dir + +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {Array} args + */ +async function listKeys({ dashApi }, args) { + let wifnames = await listManagedKeynames(); + + /** + * @type Array<{ node: String, error: Error }> + */ + let warns = []; + console.info(``); + console.info(`Staking keys: (in ${keysDirRel}/)`); + 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 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; + } + */ + + process.stdout.write(` 🪙 ${addr}: `); + let balanceInfo = await dashApi.getInstantBalance(addr); + let balanceDash = toDash(balanceInfo.balanceSat); + console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`); + }, Promise.resolve()); + console.info(``); + + if (warns.length) { + console.warn(`Warnings:`); + warns.forEach(function (warn) { + console.warn(`${warn.node}: ${warn.error.message}`); + }); + console.warn(``); + } +} + +/** + * @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; +} + +/** + * @param {Object} opts + * @param {any} opts.dashApi - TODO + * @param {String} opts.defaultAddr + * @param {String} opts.insightBaseUrl + * @param {Array} args + */ +async function removeKey({ dashApi, defaultAddr, insightBaseUrl }, args) { + let [addr, name] = await mustGetAddr({ defaultAddr }, 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; + } + + await initCrowdNode(insightBaseUrl); + let crowdNodeBalance = await CrowdNode.http.GetBalance(addr); + if (!crowdNodeBalance) { + // may be janky if not registered + crowdNodeBalance = {}; + } + if (!crowdNodeBalance.TotalBalance) { + //console.log('DEBUG', crowdNodeBalance); + crowdNodeBalance.TotalBalance = 0; + } + 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; + } + + let wifname = await findWif(addr); + let filepath = Path.join(keysDir, wifname); + let wif = await maybeReadKeyPaths(name, { wif: true }); + + await Fs.unlink(filepath).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 { - await Fs.writeFile(filepath, wif, "utf8"); - console.info(`Generated ${filepath} ${note}`); + let newAddr = wifnames[0]; + console.info(`Selected ${newAddr} as new default staking key.`); + await Fs.writeFile(defaultWifPath, addr.replace(".wif", ""), "utf8"); + console.info(``); + } +} + +/** + * @param {String} pre + */ +async function findWif(pre) { + if (!pre) { + return ""; + } + + let names = await listManagedKeynames(); + names = names.filter(function (name) { + return name.startsWith(pre); + }); + + if (!names.length) { + return ""; } - let desiredAmountDash = parseFloat(args.shift() || 0); + if (names.length > 1) { + console.error(`'${pre}' is ambiguous:`, names.join(", ")); + process.exit(1); + return ""; + } + + return names[0]; +} + +async function listManagedKeynames() { + let nodes = await Fs.readdir(keysDir); + + return nodes.filter(isNamedLikeKey); +} + +/** + * @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 = ""; @@ -376,12 +1235,14 @@ async function load(psuedoState, args) { effectiveDash = toDash(effectiveDuff); // Round to the nearest mDash // ex: 0.50238108 => 0.50300000 - effectiveDuff = toDuff(Math.ceil(effectiveDash * 1000) / 1000); + effectiveDuff = toDuff( + (Math.ceil(parseFloat(effectiveDash) * 1000) / 1000).toString(), + ); effectiveDash = toDash(effectiveDuff); } console.info(``); - showQr(pub, effectiveDuff); + showQr(addr, effectiveDuff); console.info(``); console.info( `Use the QR Code above to load ${effectiveDuff} (Đ${effectiveDash}) onto your staking key.`, @@ -389,27 +1250,50 @@ async function load(psuedoState, args) { console.info(``); console.info(`(waiting...)`); console.info(``); - let payment = await Ws.waitForVout(psuedoState.insightBaseUrl, pub, 0); + let payment = await Ws.waitForVout(insightBaseUrl, addr, 0); console.info(`Received ${payment.satoshis}`); process.exit(0); } -async function balance(args, state) { - console.info(state.balanceInfo); +/** + * @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); + let balanceInfo = await checkBalance({ addr, dashApi }); + console.info(balanceInfo); process.exit(0); return; } +/** + * @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 transfer(args, state) { - let newAddr = await wifFileToAddr(process.argv[4]); - let dashAmount = parseFloat(process.argv[5] || 0); +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 (duffAmount) { - tx = await state.dashApi.createPayment(state.privKey, newAddr, duffAmount); + tx = await dashApi.createPayment(wif, newAddr, duffAmount); } else { - tx = await state.dashApi.createBalanceTransfer(state.privKey, newAddr); + tx = await dashApi.createBalanceTransfer(wif, newAddr); } if (duffAmount) { let dashAmountStr = toDash(duffAmount); @@ -419,33 +1303,53 @@ async function transfer(args, state) { } 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(); - let pk = new Dashcore.PrivateKey(state.privKey); - let pub = pk.toPublicKey().toAddress().toString(); - let crowdNodeBalance = await CrowdNode.http.GetBalance(pub); + 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 crowdNodeDash = toDash(crowdNodeBalance.TotalBalance); console.info( `CrowdNode Stake: ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash})`, @@ -455,11 +1359,22 @@ async function status(args, state) { 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`); @@ -467,12 +1382,15 @@ async function signup(args, state) { return; } - let hasEnough = state.balanceInfo.balanceSat > signupOnly + feeEstimate; + 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); + await CrowdNode.signup(wif, hotwallet); state.signup = "✅"; console.info(` ${state.signup} SignUpForApi`); console.info(` ${state.accept} AcceptTerms`); @@ -480,23 +1398,46 @@ async function signup(args, state) { 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 > acceptOnly + feeEstimate; + 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); + await CrowdNode.accept(wif, hotwallet); state.accept = "✅"; console.info(` ${state.signup} SignUpForApi`); console.info(` ${state.accept} AcceptTerms`); @@ -505,9 +1446,26 @@ async function accept(args, state) { 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; } @@ -515,7 +1473,7 @@ async function deposit(args, state) { // this would allow for at least 2 withdrawals costing (21000 + 1000) let reserve = 50000; let reserveDash = toDash(reserve); - if (!state.noReserve) { + if (!noReserve) { console.info( `reserving ${reserve} (Đ${reserveDash}) for withdrawals (--no-reserve to disable)`, ); @@ -527,48 +1485,63 @@ 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 desiredAmountDash = parseFloat(args.shift() || 0); + 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 (desiredAmountDuff) { - ask = desiredAmountDuff + reserve + -state.balanceInfo.balanceSat; + 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) { - let balanceDash = toDash(state.balanceInfo.balanceSat); + 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} (Đ${balanceDash})`, + `Balance is still too small: ${balanceInfo.balanceSat} (Đ${balanceDash})`, ); process.exit(1); return; } } if (!desiredAmountDuff) { - effectiveAmount = state.balanceInfo.balanceSat - reserve; + effectiveAmount = balanceInfo.balanceSat - reserve; } let effectiveDash = toDash(effectiveAmount); console.info( `Initiating deposit of ${effectiveAmount} (Đ${effectiveDash})...`, ); - await CrowdNode.deposit(state.privKey, state.hotwallet, effectiveAmount); + + let wif = await maybeReadKeyPaths(name, { wif: true }); + + await CrowdNode.deposit(wif, hotwallet, effectiveAmount); state.deposit = "✅"; 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; } @@ -591,7 +1564,10 @@ async function withdrawal(args, state) { let realPercentStr = (permil / 10).toFixed(1); console.info(`Initiating withdrawal of ${realPercentStr}...`); - let paid = await CrowdNode.withdrawal(state.privKey, state.hotwallet, permil); + 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}`); @@ -599,41 +1575,45 @@ async function withdrawal(args, state) { 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) { +/** + * @param {String} insightBaseUrl + * @param {String} addr + */ +async function collectSignupFees(insightBaseUrl, addr) { console.info(``); - showQr(pub); + showQr(addr); let signupTotalDash = toDash(signupTotal); let signupMsg = `Please send >= ${signupTotal} (Đ${signupTotalDash}) to Sign Up to CrowdNode`; @@ -649,18 +1629,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, duffAmount) { +/** + * @param {String} insightBaseUrl + * @param {String} addr + * @param {Number} duffAmount + */ +async function collectDeposit(insightBaseUrl, addr, duffAmount) { console.info(``); - showQr(pub, duffAmount); + showQr(addr, duffAmount); - let depositMsg = `Please send what you wish to deposit to ${pub}`; + let depositMsg = `Please send what you wish to deposit to ${addr}`; if (duffAmount) { - let depositDash = toDash(duffAmount); - depositMsg = `Please deposit ${duffAmount} (Đ${depositDash}) to ${pub}`; + let dashAmount = toDash(duffAmount); + depositMsg = `Please deposit ${duffAmount} (Đ${dashAmount}) to ${addr}`; } let msgPad = Math.ceil((qrWidth - depositMsg.length) / 2); @@ -673,14 +1658,31 @@ async function collectDeposit(insightBaseUrl, pub, duffAmount) { 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 {String} dash - ex: 0.00000000 + */ function toDuff(dash) { return Math.round(parseFloat(dash) * DUFFS); } diff --git a/cli/README.md b/cli/README.md index 2378d72..df49480 100644 --- a/cli/README.md +++ b/cli/README.md @@ -83,17 +83,22 @@ You will be given these options whenever the existing balance is low. ```bash Usage: crowdnode help - crowdnode status ./privkey.wif - crowdnode signup ./privkey.wif - crowdnode accept ./privkey.wif - crowdnode deposit ./privkey.wif [dash-amount] [--no-reserve] - crowdnode withdrawal ./privkey.wif # 1.0-100.0 (steps by 0.1) + crowdnode status [keyfile-or-addr] + crowdnode signup [keyfile-or-addr] + crowdnode accept [keyfile-or-addr] + crowdnode deposit [keyfile-or-addr] [dash-amount] [--no-reserve] + crowdnode withdrawal [keyfile-or-addr] # 1.0-100.0 (steps by 0.1) Helpful Extras: crowdnode generate [./privkey.wif] - crowdnode load [./privkey.wif] [dash-amount] - crowdnode balance ./privkey.wif - crowdnode transfer ./source.wif [dash-amount] + crowdnode list + crowdnode encrypt # TODO + crowdnode decrypt # TODO + crowdnode use + crowdnode load [keyfile-or-addr] [dash-amount] + crowdnode balance [keyfile-or-addr] + crowdnode transfer [dash-amount] + crowdnode rm CrowdNode HTTP RPC: crowdnode http FundsOpen diff --git a/tsconfig.json b/tsconfig.json index 36b3c9e..348b8f6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -71,6 +71,6 @@ "skipLibCheck": true /* Skip type checking of declaration files. */, "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ }, - "include": ["./types.js", "server.js", "lib/**/*.js"], + "include": ["./types.js", "server.js", "bin/**/*.js", "lib/**/*.js"], "exclude": ["node_modules"] } -- 2.25.1