+ await Fs.rename(filepath, bakpath);
+ }
+ await Fs.rename(tmpPath, filepath);
+ if (!err) {
+ await Fs.unlink(bakpath);
+ }
+}
+
+/**
+ * @param {Null} psuedoState
+ * @param {Array<String>} 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<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) {
+ 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<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 {Array<String>} 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<String>} 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(``);