+ console.info("Official CrowdNode Resources");
+ console.info("");
+ console.info("Homepage:");
+ console.info(" https://crowdnode.io/");
+ console.info("");
+ console.info("Terms of Service:");
+ console.info(" https://crowdnode.io/terms/");
+ console.info("");
+ console.info("BlockChain API Guide:");
+ console.info(
+ " https://knowledge.crowdnode.io/en/articles/5963880-blockchain-api-guide",
+ );
+ console.info("");
+}
+
+let cmds = {};
+
+async function main() {
+ /*jshint maxcomplexity:40 */
+ /*jshint maxstatements:500 */
+
+ // Usage:
+ // crowdnode <subcommand> [flags] <privkey> [options]
+ // Example:
+ // crowdnode withdrawal ./Xxxxpubaddr.wif 100.0
+
+ let args = process.argv.slice(2);
+
+ // flags
+ let forceGenerate = removeItem(args, "--create-new");
+ let forceConfirm = removeItem(args, "--unconfirmed");
+ let plainText = removeItem(args, "--plain-text");
+ let noReserve = removeItem(args, "--no-reserve");
+
+ let subcommand = args.shift();
+
+ if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) {
+ showHelp();
+ process.exit(0);
+ return;
+ }
+
+ if (["--version", "-V", "version"].includes(subcommand)) {
+ showVersion();
+ process.exit(0);
+ return;
+ }
+
+ //
+ //
+ // find addr by name or by file or by string
+ await Fs.mkdir(keysDir, {
+ recursive: true,
+ });
+
+ let defaultAddr = await Fs.readFile(defaultWifPath, "utf8").catch(
+ emptyStringOnErrEnoent,
+ );
+ defaultAddr = defaultAddr.trim();
+
+ let insightBaseUrl =
+ process.env.INSIGHT_BASE_URL || "https://insight.dash.org";
+ let insightApi = Insight.create({ baseUrl: insightBaseUrl });
+ let dashApi = Dash.create({ insightApi: insightApi });
+
+ if ("stake" === subcommand) {
+ await stakeDash(
+ {
+ dashApi,
+ insightApi,
+ insightBaseUrl,
+ defaultAddr,
+ forceGenerate,
+ noReserve,
+ },
+ args,
+ );
+ process.exit(0);
+ return;
+ }
+
+ if ("list" === subcommand) {
+ await listKeys({ dashApi, defaultAddr }, args);
+ process.exit(0);
+ return;
+ }
+
+ if ("init" === subcommand) {
+ await initKeystore({ defaultAddr });
+ process.exit(0);
+ return;
+ }
+
+ if ("generate" === subcommand) {
+ await generateKey({ defaultKey: defaultAddr, plainText }, args);
+ process.exit(0);
+ return;
+ }
+
+ if ("passphrase" === subcommand) {
+ await setPassphrase({}, args);
+ process.exit(0);
+ return;
+ }
+
+ if ("import" === subcommand) {
+ let keypath = args.shift() || "";
+ await importKey({ keypath });
+ process.exit(0);
+ return;
+ }
+
+ if ("encrypt" === subcommand) {
+ let addr = args.shift() || "";
+ if (!addr) {
+ await encryptAll(null);
+ process.exit(0);
+ return;
+ }
+
+ let keypath = await findWif(addr);
+ if (!keypath) {
+ console.error(`no managed key matches '${addr}'`);
+ process.exit(1);
+ return;
+ }
+ let key = await maybeReadKeyFileRaw(keypath);
+ if (!key) {
+ throw new Error("impossible error");
+ }
+ await encryptAll([key]);
+ process.exit(0);
+ return;
+ }
+
+ if ("decrypt" === subcommand) {
+ let addr = args.shift() || "";
+ if (!addr) {
+ await decryptAll(null);
+ await Fs.writeFile(shadowPath, NO_SHADOW, "utf8").catch(
+ emptyStringOnErrEnoent,
+ );
+ process.exit(0);
+ return;
+ }
+ let keypath = await findWif(addr);
+ if (!keypath) {
+ console.error(`no managed key matches '${addr}'`);
+ process.exit(1);
+ return;
+ }
+ let key = await maybeReadKeyFileRaw(keypath);
+ if (!key) {
+ throw new Error("impossible error");
+ }
+ await decryptAll([key]);
+ process.exit(0);
+ return;
+ }
+
+ // use or select or default... ?
+ if ("use" === subcommand) {
+ await setDefault(null, args);
+ process.exit(0);
+ return;
+ }
+
+ // helper for debugging
+ if ("transfer" === subcommand) {
+ await transferBalance(
+ { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
+ args,
+ );
+ process.exit(0);
+ return;
+ }
+
+ let rpc = "";
+ if ("http" === subcommand) {
+ rpc = args.shift() || "";
+ if (!rpc) {
+ showHelp();
+ process.exit(1);
+ return;
+ }
+
+ let [addr] = await mustGetAddr({ defaultAddr }, args);
+
+ await initCrowdNode(insightBaseUrl);
+ // ex: http <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(insightBaseUrl);
+ let [addr, filepath] = await mustGetAddr({ defaultAddr }, args);
+ await removeKey({ addr, dashApi, filepath, insightBaseUrl }, args);
+ process.exit(0);
+ return;
+ }
+
+ if ("balance" === subcommand) {
+ if (args.length) {
+ await getBalance({ dashApi, defaultAddr }, args);
+ process.exit(0);
+ return;
+ }
+
+ await getAllBalances({ dashApi, defaultAddr }, args);
+ process.exit(0);
+ return;
+ }
+
+ if ("status" === subcommand) {
+ await getStatus({ dashApi, defaultAddr, insightBaseUrl }, args);
+ process.exit(0);
+ return;
+ }
+
+ if ("signup" === subcommand) {
+ await sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args);
+ process.exit(0);
+ return;
+ }
+
+ if ("accept" === subcommand) {
+ await acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args);
+ process.exit(0);
+ return;
+ }
+
+ if ("deposit" === subcommand) {
+ await depositDash(
+ { dashApi, defaultAddr, insightBaseUrl, noReserve },
+ args,
+ );
+ process.exit(0);
+ return;
+ }
+
+ if ("withdrawal" === subcommand) {
+ await withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args);
+ process.exit(0);
+ return;
+ }
+
+ console.error(`Unrecognized subcommand ${subcommand}`);
+ console.error();
+ showHelp();
+ process.exit(1);
+}
+
+/**
+ * @param {Object} opts
+ * @param {any} opts.dashApi - TODO
+ * @param {String} opts.defaultAddr
+ * @param {Boolean} opts.forceGenerate
+ * @param {String} opts.insightBaseUrl
+ * @param {any} opts.insightApi
+ * @param {Boolean} opts.noReserve
+ * @param {Array<String>} args
+ */
+async function stakeDash(
+ {
+ dashApi,
+ defaultAddr,
+ forceGenerate,
+ insightApi,
+ insightBaseUrl,
+ noReserve,
+ },
+ args,
+) {
+ let err = await Fs.access(args[0]).catch(Object);
+ let addr;
+ if (!err) {
+ let keypath = args.shift() || "";
+ addr = await importKey({ keypath });
+ } else if (forceGenerate) {
+ addr = await generateKey({ defaultKey: defaultAddr }, []);
+ } else {
+ addr = await initKeystore({ defaultAddr });
+ }
+
+ if (!addr) {
+ let [_addr] = await mustGetAddr({ defaultAddr }, args);
+ addr = _addr;
+ }
+
+ let extra = feeEstimate;
+ console.info("Checking CrowdNode account... ");
+ await CrowdNode.init({
+ baseUrl: "https://app.crowdnode.io",
+ insightBaseUrl,
+ });
+ let hotwallet = CrowdNode.main.hotwallet;
+ let state = await getCrowdNodeStatus({ addr, hotwallet });
+
+ if (!state.status?.accept) {
+ if (!state.status?.signup) {
+ let signUpDeposit = signupOnly + feeEstimate;
+ console.info(
+ ` ${TODO} SignUpForApi deposit is ${signupOnly} (+ tx fee)`,
+ );
+ extra += signUpDeposit;
+ } else {
+ console.info(` ${DONE} SignUpForApi complete`);
+ }
+ let acceptDeposit = acceptOnly + feeEstimate;
+ console.info(` ${TODO} AcceptTerms deposit is ${acceptOnly} (+ tx fee)`);
+ extra += acceptDeposit;
+ }
+
+ let desiredAmountDash = args.shift() || "0.5";
+ let effectiveDuff = toDuff(desiredAmountDash);
+ effectiveDuff += extra;
+
+ let balanceInfo = await dashApi.getInstantBalance(addr);
+ effectiveDuff -= balanceInfo.balanceSat;
+
+ if (effectiveDuff > 0) {
+ effectiveDuff = roundDuff(effectiveDuff, 3);
+ let effectiveDash = toDash(effectiveDuff);
+ await plainLoadAddr({
+ addr,
+ effectiveDash,
+ effectiveDuff,
+ insightBaseUrl,
+ });
+ }
+
+ if (!state.status?.accept) {
+ if (!state.status?.signup) {
+ await sendSignup({ dashApi, defaultAddr: addr, insightBaseUrl }, [addr]);
+ }
+ await acceptTerms({ dashApi, defaultAddr: addr, insightBaseUrl }, [addr]);
+ }
+
+ await depositDash(
+ { dashApi, defaultAddr: addr, insightBaseUrl, noReserve },
+ [addr].concat(args),
+ );
+}
+
+/**
+ * @param {Object} opts
+ * @param {String} opts.defaultAddr
+ */
+async function initKeystore({ defaultAddr }) {
+ // if we have no keys, make one
+ let wifnames = await listManagedKeynames();
+ if (!wifnames.length) {
+ return await generateKey({ defaultKey: defaultAddr }, []);
+ }
+ // if we have no passphrase, ask about it
+ await initPassphrase();
+ return defaultAddr || wifnames[0];
+}
+
+/**
+ * @param {String} insightBaseUrl
+ */
+async function initCrowdNode(insightBaseUrl) {
+ if (CrowdNode.main.hotwallet) {
+ return;
+ }
+ process.stdout.write("Checking CrowdNode API... ");
+ await CrowdNode.init({
+ baseUrl: "https://app.crowdnode.io",
+ insightBaseUrl,
+ });
+ console.info(`(hotwallet ${CrowdNode.main.hotwallet})`);
+}
+
+/**
+ * @param {String} addr - Base58Check pubKeyHash address
+ * @param {Number} duffs - 1/100000000 of a DASH
+ */
+function showQr(addr, duffs = 0) {
+ let dashAmount = toDash(duffs);
+ let dashUri = `dash://${addr}`;
+ if (duffs) {
+ dashUri += `?amount=${dashAmount}`;
+ }
+
+ let dashQr = Qr.ascii(dashUri, { indent: 4, size: "mini" });
+ let addrPad = Math.max(0, Math.ceil((qrWidth - dashUri.length) / 2));
+
+ console.info(dashQr);
+ console.info();
+ console.info(" ".repeat(addrPad) + dashUri);
+}
+
+/**
+ * @param {Array<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 set an encryption passphrase? [Y/n]: ",
+ );
+ }
+
+ // Set a passphrase and create shadow file
+ if (!no || ["yes", "y"].includes(no.toLowerCase())) {
+ result = await setPassphrase({ _askPreviousPassphrase: false }, args);
+ cmds._setPassphrase(result.passphrase);
+ return result;
+ }
+
+ // ask user again
+ if (!["no", "n"].includes(no.toLowerCase())) {
+ continue;
+ }
+
+ // No passphrase, create a NONE shadow file
+ await Fs.writeFile(shadowPath, NO_SHADOW, "utf8");
+ return result;
+ }
+ }
+
+ // State 2: shadow already initialized to empty
+ // (user doesn't want a passphrase)
+ if (!shadow) {
+ cmds._setPassphrase("");
+ return result;
+ }
+
+ // State 3: passphrase & shadow already in use
+ for (;;) {
+ let prompt = `Enter passphrase: `;
+ if (_rotatePassphrase) {
+ prompt = `Enter (current) passphrase: `;
+ }
+ result.passphrase = await Prompt.prompt(prompt, {
+ mask: true,
+ });
+ result.passphrase = result.passphrase.trim();
+ if (!result.passphrase || "q" === result.passphrase) {
+ console.error("cancel: no passphrase");
+ process.exit(1);
+ return result;
+ }
+
+ let match = await Cipher.checkPassphrase(result.passphrase, shadow);
+ if (match) {
+ cmds._setPassphrase(result.passphrase);
+ console.info(``);
+ return result;
+ }
+
+ console.error("incorrect passphrase");
+ }
+
+ throw new Error("SANITY FAIL: unreachable return");
+};
+
+cmds._getPassphrase = function () {
+ return "";
+};
+
+/**
+ * @param {String} passphrase
+ */
+cmds._setPassphrase = function (passphrase) {
+ // Look Ma! A private variable!
+ cmds._getPassphrase = function () {
+ return passphrase;
+ };
+};
+
+/**
+ * Encrypt ALL-the-things!
+ * @param {Object} [opts]
+ * @param {Boolean} opts.rotateKey
+ * @param {Array<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;