UNPKG

crowdnode

Version:

Manage your stake in Đash with the CrowdNode Blockchain API

1,905 lines (1,682 loc) 56.2 kB
#!/usr/bin/env node "use strict"; /*jshint maxcomplexity:25 */ 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("../crowdnode.js"); let Dash = require("../dashapi.js"); let Dashsight = require("dashsight"); let Prompt = require("./_prompt.js"); let Qr = require("./_qr-node.js"); let Ws = require("dashsight/ws"); let Dashcore = require("@dashevo/dashcore-lib"); const DONE = "✅"; const TODO = "ℹ️"; const NO_SHADOW = "NONE"; const DUFFS = 100000000; 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 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() { console.info(`${pkg.name} v${pkg.version} - ${pkg.description}`); console.info(); } 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 version"); console.info(""); 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 withdraw [keyfile-or-addr] <percent> # 1.0-100.0 (steps by 0.1)", ); console.info(""); console.info("Helpful Extras:"); console.info(" crowdnode balance [keyfile-or-addr]"); // addr console.info(" crowdnode load [keyfile-or-addr] [dash-amount]"); // addr console.info( " crowdnode transfer <from-keyfile-or-addr> <to-keyfile-or-addr> [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 <addr>"); console.info(" crowdnode import <keyfile>"); //console.info(" crowdnode import <(dash-cli dumpprivkey <addr>)"); // TODO //console.info(" crowdnode export <addr> <keyfile>"); // TODO console.info(" crowdnode passphrase # set or change passphrase"); console.info(" crowdnode decrypt"); // TODO allow decrypting one-by-one? console.info(" crowdnode delete <addr>"); console.info(""); console.info("CrowdNode HTTP RPC:"); console.info(" crowdnode http FundsOpen <addr>"); console.info(" crowdnode http VotingOpen <addr>"); console.info(" crowdnode http GetFunds <addr>"); console.info(" crowdnode http GetFundsFrom <addr> <seconds-since-epoch>"); console.info(" crowdnode http GetBalance <addr>"); console.info(" crowdnode http GetMessages <addr>"); console.info(" crowdnode http IsAddressInUse <addr>"); // TODO create signature rather than requiring it console.info(" crowdnode http SetEmail ./privkey.wif <email> <signature>"); console.info(" crowdnode http Vote ./privkey.wif <gobject-hash> "); console.info(" <Yes|No|Abstain|Delegate|DoNothing> <signature>"); console.info( " crowdnode http SetReferral ./privkey.wif <referral-id> <signature>", ); console.info(""); 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 = {}; let dashsightBaseUrl = process.env.DASHSIGHT_BASE_URL || "https://dashsight.dashincubator.dev/insight-api"; let dashsocketBaseUrl = process.env.DASHSOCKET_BASE_URL || "https://insight.dash.org/socket.io"; let insightBaseUrl = process.env.INSIGHT_BASE_URL || "https://insight.dash.org/insight-api"; async function main() { /*jshint maxcomplexity:40 */ /*jshint maxstatements:500 */ // Usage: // crowdnode <subcommand> [flags] <privkey> [options] // Example: // crowdnode withdraw ./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 dashsightApi = Dashsight.create({ dashsightBaseUrl: dashsightBaseUrl, dashsocketBaseUrl: dashsocketBaseUrl, insightBaseUrl: insightBaseUrl, }); let dashApi = Dash.create({ insightApi: dashsightApi }); if ("stake" === subcommand) { await stakeDash( { dashApi, insightApi: dashsightApi, 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, insightApi: dashsightApi, }, 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(); // ex: http <rpc>(<pub>, ...) 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(); 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, noReserve }, args); process.exit(0); return; } // The misspelling 'withdrawal' is kept as part of compatibility < v1.7 if ("withdrawal" === subcommand) { console.warn( `[Deprecation Notice] 'crowdnode withdrawal' is a misspelling of 'crowdnode withdraw'`, ); subcommand = "withdraw"; } if ("withdraw" === subcommand) { await withdrawDash({ 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<String>} args */ async function stakeDash( { dashApi, defaultAddr, forceGenerate, insightApi, 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", dashsightBaseUrl, dashsocketBaseUrl, 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, }); } 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, noReserve }, [addr].concat(args), ); await checkBalance({ addr, dashApi }); } /** * @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]; } async function initCrowdNode() { if (CrowdNode._initialized) { return; } process.stdout.write("Checking CrowdNode API... "); await CrowdNode.init({ baseUrl: "https://app.crowdnode.io", dashsightBaseUrl, dashsocketBaseUrl, 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<any>} 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<String>} 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<String>} 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<String>} - 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 <addr>`); 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<String>} 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<String>} 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<String>} 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 encrypt your keys with a 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<RawKey>?} 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<RawKey>?} 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<RawKey> */ 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<String>} */ 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<RawKey?>} */ 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); } // 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; } 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); } /** * @param {Null} _ * @param {Array<String>} 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 {String} opts.defaultAddr * @param {Array<String>} args */ async function listKeys({ dashApi, defaultAddr }, args) { let wifnames = await listManagedKeynames(); if (wifnames) { // to print 'default staking key' message await mustGetAddr({ defaultAddr }, args); } /** * @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; } console.info(`${addr}`); }, Promise.resolve()); debug(``); if (warns.length) { console.warn(`Warnings:`); warns.forEach(function (warn) { console.warn(`${warn.node}: ${warn.error.message}`); }); console.warn(``); } } /** * @param {Object} opts * @param {any} opts.dashApi - TODO * @param {String} opts.defaultAddr * @param {Array<String>} 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); } /** * @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 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); let crowdNodeBalance = await CrowdNode.http.GetBalance(addr); if (!crowdNodeBalance.TotalBalance) { crowdNodeBalance.TotalBalance = 0; crowdNodeBalance.TotalDividend = 0; } let crowdNodeDuffNum = toDuff(crowdNodeBalance.TotalBalance.toString()); let crowdNodeDASH = toDASH(crowdNodeDuffNum); let crowdNodeDivNum = toDuff(crowdNodeBalance.TotalDividend.toString()); let crowdNodeDivDASH = toDASH(crowdNodeDivNum); process.stdout.write( ` ${balanceDASH} | ${crowdNodeDASH} | ${crowdNodeDivDASH} |`, ); totals.key += balanceInfo.balanceSat; totals.dividend += crowdNodeBalance.TotalDividend; totals.stake += crowdNodeBalance.TotalBalance; console.info(); }, Promise.resolve()); console.info( `| | | | |`, ); let total = `| Totals`; totals.keyDash = toDASH(totals.key); totals.stakeDash = toDASH(toDuff(totals.stake.toString())); totals.dividendDash = toDASH(toDuff(totals.dividend.toString())); console.info( `${total} | ${totals.keyDash} | ${totals.stakeDash} | ${totals.dividendDash} |`, ); debug(``); 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.addr * @param {String} opts.filepath * @param {String} opts.insightBaseUrl * @param {Array<String>} 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; } await initCrowdNode(); let crowdNodeBalance = await CrowdNode.http.GetBalance(addr); if (!crowdNodeBalance) { // may be janky if not registered crowdNodeBalance = {}; } if (!crowdNodeBalance.TotalBalance) { 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( ` (withdraw 100.0 and transfer to another address before deleting)`, ); console.error(``); process.exit(1); 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(``); } } /** * @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 ""; } 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<String>} 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); } await plainLoadAddr({ addr, effectiveDash, effectiveDuff, insightBaseUrl }); 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 }) { console.info(``); showQr(addr, effectiveDuff); console.info(``); console.info( `Send Đ${effectiveDash} to your staking key via the QR above, or its address:`, ); console.info(`${addr}`); console.info( `(this key will be used to fund and control your CrowdNode account)`, ); console.info(``); console.info(`(waiting...)`); console.info(``); let payment = await Ws.waitForVout(dashsocketBaseUrl, addr, 0); console.info(`Received ${payment.satoshis}`); } /** * @param {Object} opts * @param {String} opts.defaultAddr * @param {any} opts.dashApi - TODO * @param {Array<String>} 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; } /** * @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<String>} args */ // ex: node ./bin/crowdnode.js transfer Xxxxx 'pub' 0.01 async function transferBalance( { dashApi, defaultAddr, forceConfirm, insightApi }, args, ) { /** @type Array<String> */ let getAddrArgs = []; // There are two cases in which we could have only 2 arguments, // and the first argument could be an address inside or outside // of the wallet. // // Ex: // crowdnode transfer {source} {dest} // crowdnode transfer {source} {dest} {amount} // crowdnode transfer {dest} {amount} // crowdnode transfer {dest} // // To disambiguate, we check if the second argument is an amount. if (3 === args.length) { getAddrArgs = args; } else if (2 === args.length) { let maybeAmount = parseFloat(args[1]); let isAddr = isNaN(maybeAmount); if (isAddr) { getAddrArgs = args; } } let wif = await mustGetWif({ defaultAddr }, getAddrArgs); 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 dashApi.createPayment(wif, newAddr, duffAmount); } else { tx = await dashApi.createBalanceTransfer(wif, newAddr); } if (duffAmount) { let dashAmountStr = toDash(duffAmount); console.info( `Transferring ${duffAmount} (Đ${dashAmountStr}) to ${newAddr}...`, ); } else { console.info(`Transferring balance to ${newAddr}...`); } 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 (forceConfirm) { console.error(`(using --unconfirmed may lead to rejected double spends)`); } process.exit(1); }, 30 * 1000); await Ws.waitForVout(dashsocketBaseUrl, newAddr, 0); console.info(`Accepted!`); return; } /** * @param {Object} opts * @param {any} opts.dashApi - TODO * @param {String} opts.defaultAddr * @param {String} opts.insightBaseUrl * @param {Array<String>} args */ async function getStatus({ dashApi, defaultAddr, insightBaseUrl }, args) { let [addr] = await mustGetAddr({ defaultAddr }, args); await initCrowdNode(); let hotwallet = CrowdNode.main.hotwallet; let state = await getCrowdNodeStatus({ addr, hotwallet }); console.info(); 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 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; } /** * @param {Object} opts * @param {any} opts.dashApi - TODO * @param {String} opts.defaultAddr * @param {String} opts.insightBaseUrl * @param {Array<String>} args */ async function sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args) { let [addr, name] = await mustGetAddr({ defaultAddr }, args); await initCrowdNode(); 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 already signed up. Here's the account status:`); console.info(` ${state.signup} SignUpForApi`); console.info(` ${state.accept} AcceptTerms`); console.info(` ${state.deposit} DepositReceived`); return; } let hasEnough = balanceInfo.balanceSat > signupOnly + feeEstimate; if (!hasEnough) { await collectSignupFees(addr); } let wif = await maybeReadKeyPaths(name, { wif: true }); console.info("Requesting account..."); await CrowdNode.signup(wif, hotwallet); state.signup = DONE; console.info(` ${state.signup} SignUpForApi`); return; } /** * @param {Object} opts * @param {any} opts.dashApi - TODO * @param {String} opts.defaultAddr * @param {String} opts.insightBaseUrl * @param {Array<String>} args */ async function acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args) { let [addr, name] = await mustGetAddr({ defaultAddr }, args); await initCrowdNode(); 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(`${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`); return; } let hasEnough = balanceInfo.balanceSat > acceptOnly + feeEstimate; if (!hasEnough) { await collectSignupFees(addr); } let wif = await maybeReadKeyPaths(name, { wif: true }); console.info("Accepting terms..."); await CrowdNode.accept(wif, hotwallet); state.accept = DONE; console.info(` ${state.accept} AcceptTerms`); return; } /** * @param {Object} opts * @param {any} opts.dashApi - TODO * @param {String} opts.defaultAddr * @param {String} opts.insightBaseUrl * @param {Boolean} opts.noReserve * @param {Array<String>} args */ async function depositDash({ dashApi, defaultAddr, noReserve }, args) { let [addr, name] = await mustGetAddr({ defaultAddr }, args); await initCrowdNode(); 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