docs: add official CrowdNode docs to help output
[crowdnode.js/.git] / bin / crowdnode.js
index 7815cbf5c329d4873cc679abfc48e13a4c3c6ed7..adb6306402070cf5a6c2f3637e55cee0db490aae 100755 (executable)
@@ -23,8 +23,13 @@ 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
@@ -44,6 +49,11 @@ let keysDirRel = `~/${configdir}/keys`;
 let shadowPath = Path.join(HOME, `${configdir}/shadow`);
 let defaultWifPath = Path.join(HOME, `${configdir}/default`);
 
+function debug() {
+  //@ts-ignore
+  console.error.apply(console, arguments);
+}
+
 function showVersion() {
   console.info(`${pkg.name} v${pkg.version} - ${pkg.description}`);
   console.info();
@@ -52,6 +62,11 @@ 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 [keyfile-or-addr]");
@@ -74,6 +89,7 @@ function showHelp() {
   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");
@@ -102,6 +118,19 @@ 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 = {};
@@ -118,6 +147,7 @@ async function main() {
   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");
@@ -153,30 +183,58 @@ async function main() {
   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 }, args);
+    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) {
-    importKey(null, args);
+    let keypath = args.shift() || "";
+    await importKey({ keypath });
+    process.exit(0);
     return;
   }
 
   if ("encrypt" === subcommand) {
     let addr = args.shift() || "";
     if (!addr) {
-      encryptAll(null);
+      await encryptAll(null);
+      process.exit(0);
       return;
     }
 
@@ -190,14 +248,19 @@ async function main() {
     if (!key) {
       throw new Error("impossible error");
     }
-    encryptAll([key]);
+    await encryptAll([key]);
+    process.exit(0);
     return;
   }
 
   if ("decrypt" === subcommand) {
     let addr = args.shift() || "";
     if (!addr) {
-      decryptAll(null);
+      await decryptAll(null);
+      await Fs.writeFile(shadowPath, NO_SHADOW, "utf8").catch(
+        emptyStringOnErrEnoent,
+      );
+      process.exit(0);
       return;
     }
     let keypath = await findWif(addr);
@@ -210,13 +273,15 @@ async function main() {
     if (!key) {
       throw new Error("impossible error");
     }
-    decryptAll([key]);
+    await decryptAll([key]);
+    process.exit(0);
     return;
   }
 
   // use or select or default... ?
   if ("use" === subcommand) {
     await setDefault(null, args);
+    process.exit(0);
     return;
   }
 
@@ -226,6 +291,7 @@ async function main() {
       { dashApi, defaultAddr, forceConfirm, insightBaseUrl, insightApi },
       args,
     );
+    process.exit(0);
     return;
   }
 
@@ -259,39 +325,52 @@ async function main() {
     } 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);
-    await removeKey({ defaultAddr, dashApi, insightBaseUrl }, args);
+    let [addr, filepath] = await mustGetAddr({ defaultAddr }, args);
+    await removeKey({ addr, dashApi, filepath, insightBaseUrl }, args);
+    process.exit(0);
     return;
   }
 
   if ("balance" === subcommand) {
-    await getBalance({ dashApi, defaultAddr }, args);
+    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;
   }
 
@@ -300,11 +379,13 @@ async function main() {
       { dashApi, defaultAddr, insightBaseUrl, noReserve },
       args,
     );
+    process.exit(0);
     return;
   }
 
   if ("withdrawal" === subcommand) {
     await withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args);
+    process.exit(0);
     return;
   }
 
@@ -314,10 +395,120 @@ async function main() {
   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",
@@ -338,7 +529,7 @@ function showQr(addr, duffs = 0) {
   }
 
   let dashQr = Qr.ascii(dashUri, { indent: 4, size: "mini" });
-  let addrPad = Math.ceil((qrWidth - dashUri.length) / 2);
+  let addrPad = Math.max(0, Math.ceil((qrWidth - dashUri.length) / 2));
 
   console.info(dashQr);
   console.info();
@@ -364,9 +555,9 @@ function removeItem(arr, item) {
  */
 async function getCrowdNodeStatus({ addr, hotwallet }) {
   let state = {
-    signup: "❌",
-    accept: "❌",
-    deposit: "❌",
+    signup: TODO,
+    accept: TODO,
+    deposit: TODO,
     status: {
       signup: 0,
       accept: 0,
@@ -375,15 +566,18 @@ async function getCrowdNodeStatus({ addr, hotwallet }) {
   };
 
   //@ts-ignore - TODO why warnings?
-  state.status = await CrowdNode.status(addr, hotwallet);
+  let status = await CrowdNode.status(addr, hotwallet);
+  if (status) {
+    state.status = status;
+  }
   if (state.status?.signup) {
-    state.signup = "✅";
+    state.signup = DONE;
   }
   if (state.status?.accept) {
-    state.accept = "✅";
+    state.accept = DONE;
   }
   if (state.status?.deposit) {
-    state.deposit = "✅";
+    state.deposit = DONE;
   }
   return state;
 }
@@ -395,10 +589,26 @@ async function getCrowdNodeStatus({ addr, hotwallet }) {
  */
 async function checkBalance({ addr, dashApi }) {
   // deposit if balance is over 100,000 (0.00100000)
-  process.stdout.write("Checking balance... ");
+  console.info("Checking balance... ");
   let balanceInfo = await dashApi.getInstantBalance(addr);
-  let balanceDash = toDash(balanceInfo.balanceSat);
-  console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`);
+  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) {
@@ -537,8 +747,9 @@ async function mustGetDefaultWif(defaultAddr, opts) {
     // misnomering wif here a bit
     defaultWif = raw?.wif || raw?.addr || "";
   }
-  if (defaultWif) {
-    console.info(`selected default staking key ${defaultAddr}`);
+  if (defaultWif && !shownDefault) {
+    shownDefault = true;
+    debug(`Selected default staking key ${defaultAddr}`);
     return defaultWif;
   }
 
@@ -561,7 +772,7 @@ async function mustGetDefaultWif(defaultAddr, opts) {
 /**
  * @param {Object} psuedoState
  * @param {String} psuedoState.defaultKey - addr name of default key
- * @param {Boolean} psuedoState.plainText - don't encrypt
+ * @param {Boolean} [psuedoState.plainText] - don't encrypt
  * @param {Array<String>} args
  */
 async function generateKey({ defaultKey, plainText }, args) {
@@ -586,6 +797,7 @@ async function generateKey({ defaultKey, plainText }, args) {
     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;
@@ -600,8 +812,20 @@ async function generateKey({ defaultKey, plainText }, args) {
   console.info(``);
   console.info(`Generated ${filename} ${note}`);
   console.info(``);
-  process.exit(0);
-  return;
+  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({}, []);
+  }
 }
 
 /**
@@ -610,33 +834,20 @@ async function generateKey({ defaultKey, plainText }, args) {
  * @param {Array<String>} args
  */
 async function setPassphrase({ _askPreviousPassphrase }, args) {
+  let result = {
+    passphrase: "",
+    changed: false,
+  };
   let date = getFsDateString();
 
   // get the old passphrase
   if (false !== _askPreviousPassphrase) {
-    await cmds.getPassphrase(null, []);
+    // TODO should contain the shadow?
+    await cmds.getPassphrase({ _rotatePassphrase: true }, []);
   }
 
   // get the new passphrase
-  let newPassphrase;
-  for (;;) {
-    newPassphrase = await Prompt.prompt("Enter (new) passphrase: ", {
-      mask: true,
-    });
-    newPassphrase = newPassphrase.trim();
-
-    let _newPassphrase = await Prompt.prompt("Enter passphrase again: ", {
-      mask: true,
-    });
-    _newPassphrase = _newPassphrase.trim();
-
-    let match = Cipher.secureCompare(newPassphrase, _newPassphrase);
-    if (match) {
-      break;
-    }
-
-    console.error("passphrases do not match");
-  }
+  let newPassphrase = await promptPassphrase();
   let curShadow = await Fs.readFile(shadowPath, "utf8").catch(
     emptyStringOnErrEnoent,
   );
@@ -659,7 +870,7 @@ async function setPassphrase({ _askPreviousPassphrase }, args) {
     let filepath = Path.join(HOME, `${configdir}/keys.${date}.bak`);
     console.info(``);
     console.info(`Backing up previous (encrypted) keys:`);
-    encAddrs.unshift(curShadow);
+    encAddrs.unshift(`SHADOW:${curShadow}`);
     await Fs.writeFile(filepath, encAddrs.join("\n") + "\n", "utf8");
     console.info(`  ~/${configdir}/keys.${date}.bak`);
     console.info(``);
@@ -668,16 +879,40 @@ async function setPassphrase({ _askPreviousPassphrase }, args) {
 
   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 {Null} _
- * @param {Array<String>} args
+ * @param {Object} opts
+ * @param {String} opts.keypath
  */
-async function importKey(_, args) {
-  let keypath = args.shift() || "";
+async function importKey({ keypath }) {
   let key = await maybeReadKeyFileRaw(keypath);
   if (!key?.wif) {
     console.error(`no key found for '${keypath}'`);
@@ -700,8 +935,122 @@ async function importKey(_, args) {
 
   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]
@@ -714,6 +1063,16 @@ async function encryptAll(rawKeys, opts) {
   }
   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) {
@@ -723,7 +1082,7 @@ async function encryptAll(rawKeys, opts) {
       console.info(`🙈 ${key.addr} [already encrypted]`);
       return;
     }
-    let encWif = await maybeEncrypt(key.wif);
+    let encWif = await maybeEncrypt(key.wif, { force: true });
     await safeSave(
       Path.join(keysDir, `${key.addr}.wif`),
       encWif,
@@ -764,7 +1123,7 @@ async function decryptAll(rawKeys) {
     console.info(`🔓 ${key.addr}`);
   }, Promise.resolve());
   console.info(``);
-  console.info(`Done `);
+  console.info(`Done ${DONE}`);
   console.info(``);
 }
 
@@ -796,98 +1155,6 @@ async function safeSave(filepath, wif, 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
@@ -991,7 +1258,10 @@ async function maybeReadKeyFileRaw(filepath, opts) {
 async function decrypt(encWif) {
   let passphrase = cmds._getPassphrase();
   if (!passphrase) {
-    passphrase = await cmds.getPassphrase(null, []);
+    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);
@@ -999,15 +1269,22 @@ async function decrypt(encWif) {
   return cipher.decrypt(encWif);
 }
 
+// tuple example {Promise<[String, Boolean]>}
 /**
+ * @param {Object} [opts]
+ * @param {Boolean} [opts.force]
  * @param {String} plainWif
  */
-async function maybeEncrypt(plainWif) {
+async function maybeEncrypt(plainWif, opts) {
   let passphrase = cmds._getPassphrase();
   if (!passphrase) {
-    passphrase = await cmds.getPassphrase(null, []);
+    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;
   }
 
@@ -1044,18 +1321,89 @@ async function setDefault(_, args) {
 /**
  * @param {Object} opts
  * @param {any} opts.dashApi - TODO
+ * @param {String} opts.defaultAddr
  * @param {Array<String>} args
  */
-async function listKeys({ dashApi }, 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.info(``);
-  console.info(`Staking keys: (in ${keysDirRel}/)`);
-  console.info(``);
+  // console.error because console.debug goes to stdout, not stderr
+  debug(``);
+  debug(`Staking keys: (in ${keysDirRel}/)`);
+  debug(``);
+
+  await wifnames.reduce(async function (promise, wifname) {
+    await promise;
+
+    let wifpath = Path.join(keysDir, wifname);
+    let addr = await maybeReadKeyFile(wifpath, { wif: false }).catch(function (
+      err,
+    ) {
+      warns.push({ node: wifname, error: err });
+      return "";
+    });
+    if (!addr) {
+      return;
+    }
+
+    console.info(`${addr}`);
+  }, Promise.resolve());
+  debug(``);
+
+  if (warns.length) {
+    console.warn(`Warnings:`);
+    warns.forEach(function (warn) {
+      console.warn(`${warn.node}: ${warn.error.message}`);
+    });
+    console.warn(``);
+  }
+}
+
+/**
+ * @param {Object} opts
+ * @param {any} opts.dashApi - TODO
+ * @param {String} opts.defaultAddr
+ * @param {Array<String>} args
+ */
+async function getAllBalances({ dashApi, defaultAddr }, args) {
+  let wifnames = await listManagedKeynames();
+  let totals = {
+    key: 0,
+    stake: 0,
+    dividend: 0,
+    keyDash: "",
+    stakeDash: "",
+    dividendDash: "",
+  };
+
+  if (wifnames.length) {
+    // to print 'default staking key' message
+    await mustGetAddr({ defaultAddr }, args);
+  }
+
+  /**
+   * @type Array<{ node: String, error: Error }>
+   */
+  let warns = [];
+  // console.error because console.debug goes to stdout, not stderr
+  debug(``);
+  debug(`Staking keys: (in ${keysDirRel}/)`);
+  debug(``);
+  console.info(
+    `|                                    |   🔑 Holdings |   🪧  Stakings |   💸 Earnings |`,
+  );
+  console.info(
+    `| ---------------------------------: | ------------: | ------------: | ------------: |`,
+  );
   if (!wifnames.length) {
     console.info(`    (none)`);
   }
@@ -1088,12 +1436,42 @@ async function listKeys({ dashApi }, args) {
     }
     */
 
-    process.stdout.write(`  🪙  ${addr}: `);
+    process.stdout.write(`| ${addr} |`);
+
     let balanceInfo = await dashApi.getInstantBalance(addr);
-    let balanceDash = toDash(balanceInfo.balanceSat);
-    console.info(`${balanceInfo.balanceSat} (Đ${balanceDash})`);
+    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 crowdNodeDivDASH = toDASH(crowdNodeDivNum);
+    process.stdout.write(
+      ` ${balanceDASH} | ${crowdNodeDASH} | ${crowdNodeDivDASH} |`,
+    );
+
+    totals.key += balanceInfo.balanceSat;
+    totals.dividend += crowdNodeBalance.TotalDividend;
+    totals.stake += crowdNodeBalance.TotalBalance;
+
+    console.info();
   }, Promise.resolve());
-  console.info(``);
+  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:`);
@@ -1118,12 +1496,12 @@ function isNamedLikeKey(name) {
 /**
  * @param {Object} opts
  * @param {any} opts.dashApi - TODO
- * @param {String} opts.defaultAddr
+ * @param {String} opts.addr
+ * @param {String} opts.filepath
  * @param {String} opts.insightBaseUrl
  * @param {Array<String>} args
  */
-async function removeKey({ dashApi, defaultAddr, insightBaseUrl }, args) {
-  let [addr, name] = await mustGetAddr({ defaultAddr }, args);
+async function removeKey({ addr, dashApi, filepath, insightBaseUrl }, args) {
   let balanceInfo = await dashApi.getInstantBalance(addr);
 
   let balanceDash = toDash(balanceInfo.balanceSat);
@@ -1146,7 +1524,6 @@ async function removeKey({ dashApi, defaultAddr, insightBaseUrl }, args) {
     crowdNodeBalance = {};
   }
   if (!crowdNodeBalance.TotalBalance) {
-    //console.log('DEBUG', crowdNodeBalance);
     crowdNodeBalance.TotalBalance = 0;
   }
   let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
@@ -1165,10 +1542,10 @@ async function removeKey({ dashApi, defaultAddr, insightBaseUrl }, args) {
   }
 
   let wifname = await findWif(addr);
-  let filepath = Path.join(keysDir, wifname);
-  let wif = await maybeReadKeyPaths(name, { wif: true });
+  let fullpath = Path.join(keysDir, wifname);
+  let wif = await maybeReadKeyPaths(filepath, { wif: true });
 
-  await Fs.unlink(filepath).catch(function (err) {
+  await Fs.unlink(fullpath).catch(function (err) {
     console.error(`could not remove ${filepath}: ${err.message}`);
     process.exit(1);
   });
@@ -1185,7 +1562,7 @@ async function removeKey({ dashApi, defaultAddr, insightBaseUrl }, args) {
     console.info(``);
   } else {
     let newAddr = wifnames[0];
-    console.info(`Selected ${newAddr} as new default staking key.`);
+    debug(`Selected ${newAddr} as new default staking key.`);
     await Fs.writeFile(defaultWifPath, addr.replace(".wif", ""), "utf8");
     console.info(``);
   }
@@ -1234,19 +1611,48 @@ async function loadAddr({ defaultAddr, insightBaseUrl }, args) {
 
   let desiredAmountDash = parseFloat(args.shift() || "0");
   let desiredAmountDuff = Math.round(desiredAmountDash * DUFFS);
+
   let effectiveDuff = desiredAmountDuff;
   let effectiveDash = "";
   if (!effectiveDuff) {
     effectiveDuff = CrowdNode.stakeMinimum + signupTotal + feeEstimate;
-    effectiveDash = toDash(effectiveDuff);
-    // Round to the nearest mDash
-    // ex: 0.50238108 => 0.50300000
-    effectiveDuff = toDuff(
-      (Math.ceil(parseFloat(effectiveDash) * 1000) / 1000).toString(),
-    );
+    effectiveDuff = roundDuff(effectiveDuff, 3);
     effectiveDash = toDash(effectiveDuff);
   }
 
+  await plainLoadAddr({ addr, effectiveDash, effectiveDuff, insightBaseUrl });
+
+  return;
+}
+
+/**
+ * 1000 to Round to the nearest mDash
+ * ex: 0.50238108 => 0.50300000
+ * @param {Number} effectiveDuff
+ * @param {Number} numDigits
+ */
+function roundDuff(effectiveDuff, numDigits) {
+  let n = Math.pow(10, numDigits);
+  let effectiveDash = toDash(effectiveDuff);
+  effectiveDuff = toDuff(
+    (Math.ceil(parseFloat(effectiveDash) * n) / n).toString(),
+  );
+  return effectiveDuff;
+}
+
+/**
+ * @param {Object} opts
+ * @param {String} opts.addr
+ * @param {String} opts.effectiveDash
+ * @param {Number} opts.effectiveDuff
+ * @param {String} opts.insightBaseUrl
+ */
+async function plainLoadAddr({
+  addr,
+  effectiveDash,
+  effectiveDuff,
+  insightBaseUrl,
+}) {
   console.info(``);
   showQr(addr, effectiveDuff);
   console.info(``);
@@ -1258,7 +1664,6 @@ async function loadAddr({ defaultAddr, insightBaseUrl }, args) {
   console.info(``);
   let payment = await Ws.waitForVout(insightBaseUrl, addr, 0);
   console.info(`Received ${payment.satoshis}`);
-  process.exit(0);
 }
 
 /**
@@ -1269,9 +1674,9 @@ async function loadAddr({ defaultAddr, insightBaseUrl }, args) {
  */
 async function getBalance({ dashApi, defaultAddr }, args) {
   let [addr] = await mustGetAddr({ defaultAddr }, args);
-  let balanceInfo = await checkBalance({ addr, dashApi });
-  console.info(balanceInfo);
-  process.exit(0);
+  await checkBalance({ addr, dashApi });
+  //let balanceInfo = await checkBalance({ addr, dashApi });
+  //console.info(balanceInfo);
   return;
 }
 
@@ -1322,7 +1727,6 @@ async function transferBalance(
   }, 30 * 1000);
   await Ws.waitForVout(insightBaseUrl, newAddr, 0);
   console.info(`Accepted!`);
-  process.exit(0);
   return;
 }
 
@@ -1356,12 +1760,11 @@ async function getStatus({ dashApi, defaultAddr, insightBaseUrl }, args) {
   if (!crowdNodeBalance.TotalBalance) {
     crowdNodeBalance.TotalBalance = 0;
   }
-  let crowdNodeDash = toDash(crowdNodeBalance.TotalBalance);
+  let crowdNodeDuff = toDuff(crowdNodeBalance.TotalBalance);
   console.info(
-    `CrowdNode Stake: ${crowdNodeBalance.TotalBalance} (Đ${crowdNodeDash})`,
+    `CrowdNode Stake: ${crowdNodeDuff} (Đ${crowdNodeBalance.TotalBalance})`,
   );
   console.info();
-  process.exit(0);
   return;
 }
 
@@ -1384,7 +1787,6 @@ async function sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args) {
     console.info(`    ${state.signup} SignUpForApi`);
     console.info(`    ${state.accept} AcceptTerms`);
     console.info(`    ${state.deposit} DepositReceived`);
-    process.exit(0);
     return;
   }
 
@@ -1397,10 +1799,8 @@ async function sendSignup({ dashApi, defaultAddr, insightBaseUrl }, args) {
 
   console.info("Requesting account...");
   await CrowdNode.signup(wif, hotwallet);
-  state.signup = "✅";
+  state.signup = DONE;
   console.info(`    ${state.signup} SignUpForApi`);
-  console.info(`    ${state.accept} AcceptTerms`);
-  process.exit(0);
   return;
 }
 
@@ -1432,7 +1832,6 @@ async function acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args) {
     console.info(`    ${state.signup} SignUpForApi`);
     console.info(`    ${state.accept} AcceptTerms`);
     console.info(`    ${state.deposit} DepositReceived`);
-    process.exit(0);
     return;
   }
   let hasEnough = balanceInfo.balanceSat > acceptOnly + feeEstimate;
@@ -1444,11 +1843,8 @@ async function acceptTerms({ dashApi, defaultAddr, insightBaseUrl }, args) {
 
   console.info("Accepting terms...");
   await CrowdNode.accept(wif, hotwallet);
-  state.accept = "✅";
-  console.info(`    ${state.signup} SignUpForApi`);
+  state.accept = DONE;
   console.info(`    ${state.accept} AcceptTerms`);
-  console.info(`    ${state.deposit} DepositReceived`);
-  process.exit(0);
   return;
 }
 
@@ -1527,9 +1923,8 @@ async function depositDash(
   let wif = await maybeReadKeyPaths(name, { wif: true });
 
   await CrowdNode.deposit(wif, hotwallet, effectiveAmount);
-  state.deposit = "✅";
+  state.deposit = DONE;
   console.info(`    ${state.deposit} DepositReceived`);
-  process.exit(0);
   return;
 }
 
@@ -1577,7 +1972,6 @@ async function withdrawalDash({ dashApi, defaultAddr, insightBaseUrl }, args) {
   //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;
 }
 
@@ -1686,6 +2080,14 @@ 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
  */