docs: add official CrowdNode docs to help output
[crowdnode.js/.git] / bin / crowdnode.js
index 81720cff6911fff7129732dbe957d333fd4b4ad2..adb6306402070cf5a6c2f3637e55cee0db490aae 100755 (executable)
@@ -5,20 +5,31 @@
 require("dotenv").config({ path: ".env" });
 require("dotenv").config({ path: ".env.secret" });
 
+let HOME = process.env.HOME || "";
+
+//@ts-ignore
 let pkg = require("../package.json");
 
 let Fs = require("fs").promises;
+let Path = require("path");
 
+let Cipher = require("./_cipher.js");
 let CrowdNode = require("../lib/crowdnode.js");
 let Dash = require("../lib/dash.js");
 let Insight = require("../lib/insight.js");
+let Prompt = require("./_prompt.js");
 let Qr = require("../lib/qr.js");
 let Ws = require("../lib/ws.js");
 
 let Dashcore = require("@dashevo/dashcore-lib");
 
+const DONE = "✅";
+const TODO = "ℹ️";
+const NO_SHADOW = "NONE";
 const DUFFS = 100000000;
-let qrWidth = 2 + 67 + 2;
+
+let shownDefault = false;
+let qrWidth = 2 + 33 + 2;
 // Sign Up Fees:
 //   0.00236608 // required for signup
 //   0.00002000 // TX fee estimate
@@ -31,18 +42,16 @@ 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);
+//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`);
 
-  console.info(signupQr);
-  console.info();
-  console.info(" ".repeat(addrPad) + signupUri);
+function debug() {
+  //@ts-ignore
+  console.error.apply(console, arguments);
 }
 
 function showVersion() {
@@ -53,25 +62,44 @@ function showVersion() {
 function showHelp() {
   showVersion();
 
+  console.info("Quick Start:");
+  // technically this also has [--no-reserve]
+  console.info("    crowdnode stake [addr-or-import-key | --create-new]");
+
+  console.info("");
   console.info("Usage:");
   console.info("    crowdnode help");
-  console.info("    crowdnode status ./privkey.wif");
-  console.info("    crowdnode signup ./privkey.wif");
-  console.info("    crowdnode accept ./privkey.wif");
+  console.info("    crowdnode 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 <percent> # 1.0-100.0 (steps by 0.1)",
+    "    crowdnode withdrawal [keyfile-or-addr] <percent> # 1.0-100.0 (steps by 0.1)",
   );
   console.info("");
 
   console.info("Helpful Extras:");
-  console.info("    crowdnode generate [./privkey.wif]");
-  console.info("    crowdnode balance ./privkey.wif");
+  console.info("    crowdnode balance [keyfile-or-addr]"); // addr
+  console.info("    crowdnode load [keyfile-or-addr] [dash-amount]"); // addr
   console.info(
-    "    crowdnode transfer ./source.wif <key-file-or-pub-addr> [dash-amount]",
-  );
+    "    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:");
@@ -90,348 +118,1756 @@ function showHelp() {
     "    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 = {};
+
+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;
+  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;
+}
+
+/**
+ * @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 || "";
 }
 
-async function main() {
-  // Usage:
-  //    crowdnode <subcommand> [flags] <privkey> [options]
-  // Example:
-  //    crowdnode withdrawal ./Xxxxpubaddr.wif 100.0
+/**
+ * @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 args = process.argv.slice(2);
+  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: "",
+    };
+  }
 
-  // flags
-  let forceConfirm = removeItem(args, "--unconfirmed");
-  let noReserve = removeItem(args, "--no-reserve");
+  let pk = new Dashcore.PrivateKey(privKey);
+  let pub = pk.toAddress().toString();
 
-  let subcommand = args.shift();
+  return {
+    addr: pub,
+    encrypted: encrypted,
+    wif: privKey,
+  };
+}
 
-  if (!subcommand || ["--help", "-h", "help"].includes(subcommand)) {
-    showHelp();
-    process.exit(0);
-    return;
+/**
+ * @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);
 
-  if (["--version", "-V", "version"].includes(subcommand)) {
-    showVersion();
-    process.exit(0);
-    return;
+  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;
   }
 
-  if ("generate" === subcommand) {
-    await generate(args.shift());
+  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 insightBaseUrl =
-    process.env.INSIGHT_BASE_URL || "https://insight.dash.org";
-  let insightApi = Insight.create({ baseUrl: insightBaseUrl });
-  let dashApi = Dash.create({ insightApi: insightApi });
+  let filepath = Path.join(keysDir, keyname);
+  let wif = await maybeReadKeyFile(filepath);
+  let pk = new Dashcore.PrivateKey(wif);
+  let pub = pk.toAddress().toString();
 
-  process.stdout.write("Checking CrowdNode API... ");
-  await CrowdNode.init({
-    baseUrl: "https://app.crowdnode.io",
-    insightBaseUrl,
-    insightApi: insightApi,
-  });
-  console.info(`hotwallet is ${CrowdNode.main.hotwallet}`);
+  console.info("set", defaultWifPath, pub);
+  await Fs.writeFile(defaultWifPath, pub, "utf8");
+}
 
-  let rpc = "";
-  if ("http" === subcommand) {
-    rpc = args.shift();
-    let keyfile = args.shift();
-    let pub = await wifFileToAddr(keyfile);
+// TODO option to specify config dir
 
-    // ex: http <rpc>(<pub>, ...)
-    args.unshift(pub);
-    let hasRpc = rpc in CrowdNode.http;
-    if (!hasRpc) {
-      console.error(`Unrecognized rpc command ${rpc}`);
-      console.error();
-      showHelp();
-      process.exit(1);
-    }
-    let result = await CrowdNode.http[rpc].apply(null, args);
-    if ("string" === typeof result) {
-      console.info(result);
-    } else {
-      console.info(JSON.stringify(result, null, 2));
+/**
+ * @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;
     }
-    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(``);
   }
+}
 
-  let keyfile = args.shift();
-  let privKey;
-  if (keyfile) {
-    privKey = await Fs.readFile(keyfile, "utf8");
-    privKey = privKey.trim();
-  } else {
-    privKey = process.env.PRIVATE_KEY;
+/**
+ * @param {Object} opts
+ * @param {any} opts.dashApi - TODO
+ * @param {String} opts.defaultAddr
+ * @param {Array<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);
   }
-  if (!privKey) {
-    // TODO generate private key?
-    console.error();
-    console.error(
-      `Error: you must provide either the WIF key file path or PRIVATE_KEY in .env`,
-    );
-    console.error();
-    process.exit(1);
+
+  /**
+   * @type Array<{ node: String, error: Error }>
+   */
+  let warns = [];
+  // console.error because console.debug goes to stdout, not stderr
+  debug(``);
+  debug(`Staking keys: (in ${keysDirRel}/)`);
+  debug(``);
+  console.info(
+    `|                                    |   🔑 Holdings |   🪧  Stakings |   💸 Earnings |`,
+  );
+  console.info(
+    `| ---------------------------------: | ------------: | ------------: | ------------: |`,
+  );
+  if (!wifnames.length) {
+    console.info(`    (none)`);
   }
+  await wifnames.reduce(async function (promise, wifname) {
+    await promise;
+
+    let wifpath = Path.join(keysDir, wifname);
+    let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function (
+      err,
+    ) {
+      warns.push({ node: wifname, error: err });
+      return "";
+    });
+    if (!addr) {
+      return;
+    }
 
-  let pk = new Dashcore.PrivateKey(privKey);
-  let pub = pk.toPublicKey().toAddress().toString();
+    /*
+    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;
+    }
+    */
 
-  // deposit if balance is over 100,000 (0.00100000)
-  process.stdout.write("Checking balance... ");
-  let balanceInfo = await dashApi.getInstantBalance(pub);
-  console.info(`${balanceInfo.balanceSat} (Đ${balanceInfo.balance})`);
-  /*
-  let balanceInfo = await insightApi.getBalance(pub);
-  if (balanceInfo.unconfirmedBalanceSat || balanceInfo.unconfirmedAppearances) {
-    if (!forceConfirm) {
-      console.error(
-        `Error: This address has pending transactions. Please try again in 1-2 minutes or use --unconfirmed.`,
-      );
-      console.error(balanceInfo);
-      if ("status" !== subcommand) {
-        process.exit(1);
-        return;
-      }
+    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);
+    let crowdNodeDASH = toDASH(crowdNodeDuffNum);
 
-  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: "❌",
-  };
+    let crowdNodeDivNum = toDuff(crowdNodeBalance.TotalDividend);
+    let crowdNodeDivDASH = toDASH(crowdNodeDivNum);
+    process.stdout.write(
+      ` ${balanceDASH} | ${crowdNodeDASH} | ${crowdNodeDivDASH} |`,
+    );
 
-  if ("balance" === subcommand) {
-    await balance(args, state);
-    process.exit(0);
-    return;
+    totals.key += balanceInfo.balanceSat;
+    totals.dividend += crowdNodeBalance.TotalDividend;
+    totals.stake += crowdNodeBalance.TotalBalance;
+
+    console.info();
+  }, Promise.resolve());
+  console.info(
+    `|                                    |               |               |               |`,
+  );
+  let total = `|                             Totals`;
+  totals.keyDash = toDASH(toDuff(totals.key.toString()));
+  totals.stakeDash = toDASH(toDuff(totals.stake.toString()));
+  totals.dividendDash = toDASH(toDuff(totals.dividend.toString()));
+  console.info(
+    `${total} | ${totals.stakeDash} | ${totals.stakeDash} | ${totals.dividendDash} |`,
+  );
+  debug(``);
+
+  if (warns.length) {
+    console.warn(`Warnings:`);
+    warns.forEach(function (warn) {
+      console.warn(`${warn.node}: ${warn.error.message}`);
+    });
+    console.warn(``);
   }
+}
 
-  // helper for debugging
-  if ("transfer" === subcommand) {
-    await transfer(args, state);
+/**
+ * @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;
   }
 
-  state.status = await CrowdNode.status(pub, state.hotwallet);
-  if (state.status?.signup) {
-    state.signup = "✅";
-  }
-  if (state.status?.accept) {
-    state.accept = "✅";
+  await initCrowdNode(insightBaseUrl);
+  let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
+  if (!crowdNodeBalance) {
+    // may be janky if not registered
+    crowdNodeBalance = {};
   }
-  if (state.status?.deposit) {
-    state.deposit = "✅";
+  if (!crowdNodeBalance.TotalBalance) {
+    crowdNodeBalance.TotalBalance = 0;
   }
-
-  if ("status" === subcommand) {
-    await status(args, state);
+  let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
+  if (crowdNodeBalance.TotalBalance) {
+    console.error(``);
+    console.error(`Error: ${addr}`);
+    console.error(
+      `    still staking ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash}) on CrowdNode`,
+    );
+    console.error(
+      `    (withdrawal 100.0 and transfer to another address before deleting)`,
+    );
+    console.error(``);
+    process.exit(1);
     return;
   }
 
-  if ("signup" === subcommand) {
-    await signup(args, state);
-    return;
+  let wifname = await findWif(addr);
+  let fullpath = Path.join(keysDir, wifname);
+  let wif = await maybeReadKeyPaths(filepath, { wif: true });
+
+  await Fs.unlink(fullpath).catch(function (err) {
+    console.error(`could not remove ${filepath}: ${err.message}`);
+    process.exit(1);
+  });
+
+  let wifnames = await listManagedKeynames();
+  console.info(``);
+  console.info(`No balances found. Removing ${filepath}.`);
+  console.info(``);
+  console.info(`Backup (just in case):`);
+  console.info(`    ${wif}`);
+  console.info(``);
+  if (!wifnames.length) {
+    console.info(`No keys left.`);
+    console.info(``);
+  } else {
+    let newAddr = wifnames[0];
+    debug(`Selected ${newAddr} as new default staking key.`);
+    await Fs.writeFile(defaultWifPath, addr.replace(".wif", ""), "utf8");
+    console.info(``);
   }
+}
 
-  if ("accept" === subcommand) {
-    await accept(args, state);
-    return;
+/**
+ * @param {String} pre
+ */
+async function findWif(pre) {
+  if (!pre) {
+    return "";
   }
 
-  if ("deposit" === subcommand) {
-    await deposit(args, state);
-    return;
+  let names = await listManagedKeynames();
+  names = names.filter(function (name) {
+    return name.startsWith(pre);
+  });
+
+  if (!names.length) {
+    return "";
   }
 
-  if ("withdrawal" === subcommand) {
-    await withdrawal(args, state);
-    return;
+  if (names.length > 1) {
+    console.error(`'${pre}' is ambiguous:`, names.join(", "));
+    process.exit(1);
+    return "";
   }
 
-  console.error(`Unrecognized subcommand ${subcommand}`);
-  console.error();
-  showHelp();
-  process.exit(1);
+  return names[0];
 }
 
-// Subcommands
+async function listManagedKeynames() {
+  let nodes = await Fs.readdir(keysDir);
 
-async function generate(name) {
-  let pk = new Dashcore.PrivateKey();
+  return nodes.filter(isNamedLikeKey);
+}
 
-  let pub = pk.toAddress().toString();
-  let wif = pk.toWIF();
+/**
+ * @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 filepath = `./${pub}.wif`;
-  let note = "";
-  if (name) {
-    filepath = name;
-    note = `\n(for pubkey address ${pub})`;
-  }
+  let desiredAmountDash = parseFloat(args.shift() || "0");
+  let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
 
-  let err = await Fs.access(filepath).catch(Object);
-  if (!err) {
-    // TODO show QR anyway
-    //wif = await Fs.readFile(filepath, 'utf8')
-    //showQr(pub, 0.01);
-    console.info(`'${filepath}' already exists (will not overwrite)`);
-    process.exit(0);
-    return;
+  let effectiveDuff = desiredAmountDuff;
+  let effectiveDash = "";
+  if (!effectiveDuff) {
+    effectiveDuff = CrowdNode.stakeMinimum + signupTotal + feeEstimate;
+    effectiveDuff = roundDuff(effectiveDuff, 3);
+    effectiveDash = toDash(effectiveDuff);
   }
 
-  await Fs.writeFile(filepath, wif, "utf8").then(function () {
-    console.info(``);
-    console.info(
-      `Use the QR Code below to load a test deposit of Đ0.01 onto your staking key.`,
-    );
-    console.info(``);
-    showQr(pub, 0.01);
-    console.info(``);
-    console.info(
-      `Use the QR Code above to load a test deposit of Đ0.01 onto your staking key.`,
-    );
-    console.info(``);
-    console.info(`Generated ${filepath} ${note}`);
-  });
-  process.exit(0);
+  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,
+  insightBaseUrl,
+}) {
+  console.info(``);
+  showQr(addr, effectiveDuff);
+  console.info(``);
+  console.info(
+    `Use the QR Code above to load ${effectiveDuff} (Đ${effectiveDash}) onto your staking key.`,
+  );
+  console.info(``);
+  console.info(`(waiting...)`);
+  console.info(``);
+  let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
+  console.info(`Received ${payment.satoshis}`);
 }
 
-async function balance(args, state) {
-  console.info(state.balanceInfo);
-  process.exit(0);
+/**
+ * @param {Object} opts
+ * @param {String} opts.defaultAddr
+ * @param {any} opts.dashApi - TODO
+ * @param {Array<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 ./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);
     console.info(
-      `Transferring ${duffAmount} (Đ${dashAmount}) to ${newAddr}...`,
+      `Transferring ${duffAmount} (Đ${dashAmountStr}) to ${newAddr}...`,
     );
   } else {
     console.info(`Transferring balance to ${newAddr}...`);
   }
-  await state.insightApi.instantSend(tx);
+  await insightApi.instantSend(tx);
   console.info(`Queued...`);
   setTimeout(function () {
     // TODO take a cleaner approach
     // (waitForVout needs a reasonable timeout)
     console.error(`Error: Transfer did not complete.`);
-    if (state.forceConfirm) {
+    if (forceConfirm) {
       console.error(`(using --unconfirmed may lead to rejected double spends)`);
     }
     process.exit(1);
   }, 30 * 1000);
-  await Ws.waitForVout(state.insightBaseUrl, newAddr, 0);
+  await Ws.waitForVout(insightBaseUrl, newAddr, 0);
   console.info(`Accepted!`);
-  process.exit(0);
   return;
 }
 
-async function status(args, state) {
+/**
+ * @param {Object} opts
+ * @param {any} opts.dashApi - TODO
+ * @param {String} opts.defaultAddr
+ * @param {String} opts.insightBaseUrl
+ * @param {Array<String>} args
+ */
+async function getStatus({ dashApi, defaultAddr, insightBaseUrl }, args) {
+  let [addr] = await mustGetAddr({ defaultAddr }, args);
+  await initCrowdNode(insightBaseUrl);
+  let hotwallet = CrowdNode.main.hotwallet;
+  let state = await getCrowdNodeStatus({ addr, hotwallet });
+
   console.info();
-  console.info(`API Actions Complete for ${state.pub}:`);
+  console.info(`API Actions Complete for ${addr}:`);
   console.info(`    ${state.signup} SignUpForApi`);
   console.info(`    ${state.accept} AcceptTerms`);
   console.info(`    ${state.deposit} DepositReceived`);
   console.info();
-  process.exit(0);
+  let crowdNodeBalance = await CrowdNode.http.GetBalance(addr);
+  // may be unregistered / undefined
+  /*
+   * {
+   *   '@odata.context': 'https://app.crowdnode.io/odata/$metadata#Edm.String',
+   *   value: 'Address not found.'
+   * }
+   */
+  if (!crowdNodeBalance.TotalBalance) {
+    crowdNodeBalance.TotalBalance = 0;
+  }
+  let crowdNodeDuff = toDuff(crowdNodeBalance.TotalBalance);
+  console.info(
+    `CrowdNode Stake: ${crowdNodeDuff} (Đ${crowdNodeBalance.TotalBalance})`,
+  );
+  console.info();
   return;
 }
 
-async function signup(args, state) {
+/**
+ * @param {Object} opts
+ * @param {any} opts.dashApi - TODO
+ * @param {String} opts.defaultAddr
+ * @param {String} opts.insightBaseUrl
+ * @param {Array<String>} args
+ */
+async function sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args) {
+  let [addr, name] = await mustGetAddr({ defaultAddr }, args);
+  await initCrowdNode(insightBaseUrl);
+  let hotwallet = CrowdNode.main.hotwallet;
+  let state = await getCrowdNodeStatus({ addr, hotwallet });
+  let balanceInfo = await checkBalance({ addr, dashApi });
+
   if (state.status?.signup) {
-    console.info(
-      `${state.pub} is already signed up. Here's the account status:`,
-    );
+    console.info(`${addr} is already signed up. Here's the account status:`);
     console.info(`    ${state.signup} SignUpForApi`);
     console.info(`    ${state.accept} AcceptTerms`);
     console.info(`    ${state.deposit} DepositReceived`);
-    process.exit(0);
     return;
   }
 
-  let hasEnough = state.balanceInfo.balanceSat > 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);
-  state.signup = "✅";
+  await CrowdNode.signup(wif, hotwallet);
+  state.signup = DONE;
   console.info(`    ${state.signup} SignUpForApi`);
-  console.info(`    ${state.accept} AcceptTerms`);
-  process.exit(0);
   return;
 }
 
-async function accept(args, state) {
+/**
+ * @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(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);
-  state.accept = "✅";
-  console.info(`    ${state.signup} SignUpForApi`);
+  await CrowdNode.accept(wif, hotwallet);
+  state.accept = DONE;
   console.info(`    ${state.accept} AcceptTerms`);
-  console.info(`    ${state.deposit} DepositReceived`);
-  process.exit(0);
   return;
 }
 
-async function deposit(args, state) {
+/**
+ * @param {Object} opts
+ * @param {any} opts.dashApi - TODO
+ * @param {String} opts.defaultAddr
+ * @param {String} opts.insightBaseUrl
+ * @param {Boolean} opts.noReserve
+ * @param {Array<String>} 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;
   }
@@ -439,7 +1875,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)`,
     );
@@ -451,51 +1887,62 @@ 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;
   }
 
-  console.info(
-    `(holding ${reserve} (Đ${reserveDash}) in reserve for API calls)`,
-  );
   let effectiveDash = toDash(effectiveAmount);
   console.info(
     `Initiating deposit of ${effectiveAmount} (Đ${effectiveDash})...`,
   );
-  await CrowdNode.deposit(state.privKey, state.hotwallet, effectiveAmount);
-  state.deposit = "✅";
+
+  let wif = await maybeReadKeyPaths(name, { wif: true });
+
+  await CrowdNode.deposit(wif, hotwallet, effectiveAmount);
+  state.deposit = DONE;
   console.info(`    ${state.deposit} DepositReceived`);
-  process.exit(0);
   return;
 }
 
-async function withdrawal(args, state) {
+/**
+ * @param {Object} opts
+ * @param {any} opts.dashApi - TODO
+ * @param {String} opts.defaultAddr
+ * @param {String} opts.insightBaseUrl
+ * @param {Array<String>} 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;
   }
@@ -518,50 +1965,57 @@ 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}`);
-  process.exit(0);
-  return;
-}
-
-/*
-async function stake(args, state) {
-  // TODO
   return;
 }
-*/
 
 // Helpers
 
-async function wifFileToAddr(keyfile) {
-  let privKey = keyfile;
-
-  let err = await Fs.access(keyfile).catch(Object);
-  if (!err) {
-    privKey = await Fs.readFile(keyfile, "utf8");
-    privKey = privKey.trim();
-  }
-
-  if (34 === privKey.length) {
+/**
+ * Convert prefix, addr, keyname, or filepath to pub addr
+ * @param {String} name
+ * @throws
+ */
+async function wifFileToAddr(name) {
+  if (34 === name.length) {
     // actually payment addr
-    return privKey;
+    return name;
   }
 
-  if (52 === privKey.length) {
-    let pk = new Dashcore.PrivateKey(privKey);
-    let pub = pk.toPublicKey().toAddress().toString();
-    return pub;
+  let privKey = "";
+
+  let wifname = await findWif(name);
+  if (wifname) {
+    let filepath = Path.join(keysDir, wifname);
+    privKey = await maybeReadKeyFile(filepath);
+  }
+  if (!privKey) {
+    privKey = await maybeReadKeyFile(name);
+  }
+  if (!privKey) {
+    throw new Error("bad file path or address");
   }
 
-  throw new Error("bad file path or address");
+  let pk = new Dashcore.PrivateKey(privKey);
+  let pub = pk.toPublicKey().toAddress().toString();
+  return pub;
 }
 
-async function collectSignupFees(insightBaseUrl, pub) {
-  showQr(pub);
+/**
+ * @param {String} insightBaseUrl
+ * @param {String} addr
+ */
+async function collectSignupFees(insightBaseUrl, addr) {
+  console.info(``);
+  showQr(addr);
 
-  let signupTotalDash = (signupTotal / DUFFS).toFixed(8);
+  let signupTotalDash = toDash(signupTotal);
   let signupMsg = `Please send >= ${signupTotal} (Đ${signupTotalDash}) to Sign Up to CrowdNode`;
   let msgPad = Math.ceil((qrWidth - signupMsg.length) / 2);
   let subMsg = "(plus whatever you'd like to deposit)";
@@ -575,17 +2029,23 @@ async function collectSignupFees(insightBaseUrl, pub) {
   console.info("");
   console.info("(waiting...)");
   console.info("");
-  let payment = await Ws.waitForVout(insightBaseUrl, pub, 0);
+  let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
   console.info(`Received ${payment.satoshis}`);
 }
 
-async function collectDeposit(insightBaseUrl, pub, duffAmount) {
-  showQr(pub, duffAmount);
+/**
+ * @param {String} insightBaseUrl
+ * @param {String} addr
+ * @param {Number} duffAmount
+ */
+async function collectDeposit(insightBaseUrl, addr, duffAmount) {
+  console.info(``);
+  showQr(addr, duffAmount);
 
-  let depositMsg = `Please send what you wish to deposit to ${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);
@@ -598,14 +2058,43 @@ 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 {Number} duffs - ex: 00000000
+ */
+function toDASH(duffs) {
+  let dash = (duffs / DUFFS).toFixed(8);
+  return `Đ` + dash.padStart(12, " ");
+}
+
+/**
+ * @param {String} dash - ex: 0.00000000
+ */
+function toDuff(dash) {
+  return Math.round(parseFloat(dash) * DUFFS);
+}
+
 // Run
 
 main().catch(function (err) {