From 97ffeabf9f6f99073320a08a5b2120a3f5f30309 Mon Sep 17 00:00:00 2001 From: JSKitty Date: Tue, 29 Aug 2023 16:19:58 +0100 Subject: [PATCH] Improved DB + Full Testnet DB support (#187) * Improved DB interface, Account class, type-safety * Simple Testnet Database + Encryption support * Fix TXs for unencrypted wallets * Hide "Secure your wallet" during switches * Fix "Import" flow breaking on network switch * Add extra safeguards + improved UI resetting --- locale/en/translation.js | 7 ++ locale/fr/translation.js | 6 + locale/ph/translation.js | 6 + locale/pt-br/translation.js | 6 + locale/pt-pt/translation.js | 6 + locale/template/translation.js | 6 + locale/uwu/translation.js | 8 ++ scripts/accounts.js | 61 +++++++++++ scripts/contacts-book.js | 102 +++++------------ scripts/database.js | 194 ++++++++++++++++++++++++++++----- scripts/global.js | 90 ++++++++------- scripts/misc.js | 38 +++++++ scripts/network.js | 2 +- scripts/settings.js | 103 ++++++++++++++--- scripts/transactions.js | 5 +- scripts/wallet.js | 69 ++++++------ 16 files changed, 504 insertions(+), 205 deletions(-) create mode 100644 scripts/accounts.js diff --git a/locale/en/translation.js b/locale/en/translation.js index cb363eb30..a696278cf 100644 --- a/locale/en/translation.js +++ b/locale/en/translation.js @@ -179,6 +179,13 @@ export const en_translation = { settingsToggleDebug: 'Debug Mode', // settingsToggleTestnet: 'Testnet Mode', // + // Network switching (mainnet <---> testnet) + netSwitchUnsavedWarningTitle: "Your {network} wallet isn't saved!", // + netSwitchUnsavedWarningSubtitle: 'Your {network} account is at risk!', // + netSwitchUnsavedWarningSubtext: + "If you switch to {network} before saving it, you'll lose the account!", // + netSwitchUnsavedWarningConfirmation: 'Are you really sure?', // + // Transparency Report transparencyReport: 'Transparency Report', hit: 'A ping indicating an app load, no unique data is sent.', diff --git a/locale/fr/translation.js b/locale/fr/translation.js index a583f8903..546df1dcc 100644 --- a/locale/fr/translation.js +++ b/locale/fr/translation.js @@ -178,6 +178,12 @@ export const fr_translation = { settingsToggleDebug: 'Mode de débogage', //Debug Mode settingsToggleTestnet: 'Mode testnet', //Testnet Mode + // Network switching (mainnet <---> testnet) + netSwitchUnsavedWarningTitle: '', //Your {network} wallet isn\'t saved! + netSwitchUnsavedWarningSubtitle: '', //Your {network} account is at risk! + netSwitchUnsavedWarningSubtext: '', //If you switch to {network} before saving it, you\'ll lose the account! + netSwitchUnsavedWarningConfirmation: '', //Are you really sure? + // Transparency Report transparencyReport: 'Rapport de transparence', //Transparency Report hit: "Un ping indiquant le chargement d'une application, aucune donnée unique n'est envoyée.", //A ping indicating an app load, no unique data is sent. diff --git a/locale/ph/translation.js b/locale/ph/translation.js index 375aeffb7..f5abb274c 100644 --- a/locale/ph/translation.js +++ b/locale/ph/translation.js @@ -179,6 +179,12 @@ export const ph_translation = { settingsToggleDebug: 'Debug Mode', //Debug Mode settingsToggleTestnet: 'Testnet Mode', //Testnet Mode + // Network switching (mainnet <---> testnet) + netSwitchUnsavedWarningTitle: '', //Your {network} wallet isn\'t saved! + netSwitchUnsavedWarningSubtitle: '', //Your {network} account is at risk! + netSwitchUnsavedWarningSubtext: '', //If you switch to {network} before saving it, you\'ll lose the account! + netSwitchUnsavedWarningConfirmation: '', //Are you really sure? + // Transparency Report transparencyReport: 'Transparency Report', //Transparency Report hit: 'Ang ping na nagpapahiwatig ng pag load ng app, walang unique na data ang naipadala.', //A ping indicating an app load, no unique data is sent. diff --git a/locale/pt-br/translation.js b/locale/pt-br/translation.js index 8044dea48..708df094d 100644 --- a/locale/pt-br/translation.js +++ b/locale/pt-br/translation.js @@ -179,6 +179,12 @@ export const pt_br_translation = { settingsToggleDebug: 'Modo de depuração', //Debug Mode settingsToggleTestnet: 'Modo Testnet', //Testnet Mode + // Network switching (mainnet <---> testnet) + netSwitchUnsavedWarningTitle: '', //Your {network} wallet isn\'t saved! + netSwitchUnsavedWarningSubtitle: '', //Your {network} account is at risk! + netSwitchUnsavedWarningSubtext: '', //If you switch to {network} before saving it, you\'ll lose the account! + netSwitchUnsavedWarningConfirmation: '', //Are you really sure? + // Transparency Report transparencyReport: '"Relatório de Transparência"', //Transparency Report hit: '"Um ping para indicar o carregamento de uma aplicação, nenhum dado exclusivo é enviado."', //A ping indicating an app load, no unique data is sent. diff --git a/locale/pt-pt/translation.js b/locale/pt-pt/translation.js index 0e1af8620..9c585c706 100644 --- a/locale/pt-pt/translation.js +++ b/locale/pt-pt/translation.js @@ -178,6 +178,12 @@ export const pt_pt_translation = { settingsToggleDebug: 'Modo de depuração', //Debug Mode settingsToggleTestnet: 'Modo Testnet', //Testnet Mode + // Network switching (mainnet <---> testnet) + netSwitchUnsavedWarningTitle: '', //Your {network} wallet isn\'t saved! + netSwitchUnsavedWarningSubtitle: '', //Your {network} account is at risk! + netSwitchUnsavedWarningSubtext: '', //If you switch to {network} before saving it, you\'ll lose the account! + netSwitchUnsavedWarningConfirmation: '', //Are you really sure? + // Transparency Report transparencyReport: '"Relatório de Transparência"', //Transparency Report hit: '"Um ping a indicar o carregamento de uma aplicação, nenhum dado exclusivo é enviado."', //A ping indicating an app load, no unique data is sent. diff --git a/locale/template/translation.js b/locale/template/translation.js index 6b3e1469a..059cebfb8 100644 --- a/locale/template/translation.js +++ b/locale/template/translation.js @@ -185,6 +185,12 @@ var translation = { settingsToggleDebug: '', //Debug Mode settingsToggleTestnet: '', //Testnet Mode + // Network switching (mainnet <---> testnet) + netSwitchUnsavedWarningTitle: '', //Your {network} wallet isn\'t saved! + netSwitchUnsavedWarningSubtitle: '', //Your {network} account is at risk! + netSwitchUnsavedWarningSubtext: '', //If you switch to {network} before saving it, you\'ll lose the account! + netSwitchUnsavedWarningConfirmation: '', //Are you really sure? + // Transparency Report transparencyReport: '', //Transparency Report hit: '', //A ping indicating an app load, no unique data is sent. diff --git a/locale/uwu/translation.js b/locale/uwu/translation.js index 381653445..0d5a014af 100644 --- a/locale/uwu/translation.js +++ b/locale/uwu/translation.js @@ -182,6 +182,14 @@ export const uwu_translation = { settingsToggleDebug: 'Debug Mowode', //Debug Mode settingsToggleTestnet: 'Testnet Mowode', //Testnet Mode + // Network switching (mainnet <---> testnet) + netSwitchUnsavedWarningTitle: "Ur {network} wawwet isn't saved!", //Your {network} wallet isn\'t saved! + netSwitchUnsavedWarningSubtitle: + 'Ur {network} account could get fucky-wuckied!', //Your {network} account is at risk! + netSwitchUnsavedWarningSubtext: + "If u switch to {network} befwore saving it, you'll lose the account!", //If you switch to {network} before saving it, you\'ll lose the account! + netSwitchUnsavedWarningConfirmation: 'Are u reaaaaaaaaally sure?', //Are you really sure? + // Transparency Report transparencyReport: 'Twanspawency Repawt', //Transparency Report hit: 'A ping indicating an app load, no unique data is sent.♡', //A ping indicated an app load, no unique data is sent. diff --git a/scripts/accounts.js b/scripts/accounts.js new file mode 100644 index 000000000..7eb48b7af --- /dev/null +++ b/scripts/accounts.js @@ -0,0 +1,61 @@ +import { Contact } from './contacts-book'; + +/** + * A local Account, containing sensitive user-data + */ +export class Account { + /** + * Create an Account. + * @param {Object} accountData - The account data. + * @param {String} accountData.publicKey - The public key. + * @param {String} [accountData.encWif] - The encrypted WIF. + * @param {Array} [accountData.localProposals] - The local proposals. + * @param {Array} [accountData.contacts] - The Contacts saved in this account. + * @param {String} [account.name] - The Contact Name of the account. + */ + constructor(accountData) { + // Keys take the Constructor as priority, but if missing, default to their "Type" in empty form for type-safety + this.publicKey = accountData?.publicKey || ''; + this.encWif = accountData?.encWif || ''; + this.localProposals = accountData?.localProposals || []; + this.contacts = accountData?.contacts || []; + this.name = accountData?.name || ''; + } + + /** @type {String} The public key. */ + publicKey = ''; + + /** @type {String} The encrypted WIF. */ + encWif = ''; + + /** @type {Array} The local proposals. */ + localProposals = []; + + /** @type {Array} The Contacts saved in this account. */ + contacts = []; + + /** @type {String} The Contact Name of the account. */ + name = ''; + + /** + * Search for a Contact in this account, by specific properties + * @param {Object} settings + * @param {string?} settings.name - The Name of the contact to search for + * @param {string?} settings.pubkey - The Pubkey of the contact to search for + * @returns {Contact?} - A Contact, if found + */ + getContactBy({ name, pubkey }) { + if (!name && !pubkey) + throw Error( + 'getContactBy(): At least ONE search parameter MUST be set!' + ); + + // Get by Name + if (name) return this.contacts.find((a) => a.label === name); + // Get by Pubkey + if (pubkey) return this.contacts.find((a) => a.pubkey === pubkey); + + // Nothing found + return null; + } +} diff --git a/scripts/contacts-book.js b/scripts/contacts-book.js index 270efee68..5be52c44f 100644 --- a/scripts/contacts-book.js +++ b/scripts/contacts-book.js @@ -1,4 +1,5 @@ import { Buffer } from 'buffer'; +import { Account } from './accounts'; import { Database } from './database'; import { doms, toClipboard } from './global'; import { ALERTS, translation } from './i18n'; @@ -56,7 +57,7 @@ export class Contact { /** * Add a Contact to an Account's contact list - * @param {{publicKey: String, encWif: String?, localProposals: Array, contacts: Array}} account - The account to add the Contact to + * @param {Account} account - The account to add the Contact to * @param {Contact} contact - The contact object */ export async function addContact(account, contact) { @@ -64,46 +65,15 @@ export async function addContact(account, contact) { const cDB = await Database.getInstance(); // Push contact in to the account - const arrContacts = account?.contacts || []; - arrContacts.push(contact); + account.contacts.push(contact); // Save to the DB - await cDB.addAccount({ - publicKey: account.publicKey, - encWif: account.encWif, - localProposals: account?.localProposals || [], - contacts: arrContacts, - name: account?.name || '', - }); -} - -/** - * Search for a Contact in a given Account, by specific properties - * @param {{publicKey: String, encWif: String?, localProposals: Array, contacts: Array}} account - The account to search for a Contact - * @param {Object} settings - * @param {string?} settings.name - The Name of the contact to search for - * @param {string?} settings.pubkey - The Pubkey of the contact to search for - * @returns {Contact?} - A Contact, if found - */ -export function getContactBy(account, { name, pubkey }) { - if (!name && !pubkey) - throw Error( - 'getContactBy(): At least ONE search parameter MUST be set!' - ); - const arrContacts = account?.contacts || []; - - // Get by Name - if (name) return arrContacts.find((a) => a.label === name); - // Get by Pubkey - if (pubkey) return arrContacts.find((a) => a.pubkey === pubkey); - - // Nothing found - return null; + await cDB.updateAccount(account); } /** * Remove a Contact from an Account's contact list - * @param {{publicKey: String, encWif: String?, localProposals: Array, contacts: Array}} account - The account to remove the Contact from + * @param {Account} account - The account to remove the Contact from * @param {string} pubkey - The contact pubkey */ export async function removeContact(account, pubkey) { @@ -111,24 +81,17 @@ export async function removeContact(account, pubkey) { const cDB = await Database.getInstance(); // Find the contact by index, if it exists; splice it away - const arrContacts = account?.contacts || []; - const nIndex = arrContacts.findIndex((a) => a.pubkey === pubkey); + const nIndex = account.contacts.findIndex((a) => a.pubkey === pubkey); if (nIndex > -1) { // Splice out the contact, and save to DB - arrContacts.splice(nIndex, 1); - await cDB.addAccount({ - publicKey: account.publicKey, - encWif: account.encWif, - localProposals: account?.localProposals || [], - contacts: account?.contacts || [], - name: account?.name || '', - }); + account.contacts.splice(nIndex, 1); + await cDB.updateAccount(account, true); } } /** * Render an Account's contact list - * @param {{publicKey: String, encWif: String?, localProposals: Array, contacts: Array}} account + * @param {Account} account * @param {boolean} fPrompt - If this is a Contact Selection prompt */ export async function renderContacts(account, fPrompt = false) { @@ -348,20 +311,17 @@ export async function guiRenderContacts() { /** * Set the current Account's Contact name - * @param {{publicKey: String, encWif: String?, localProposals: Array, contacts: Array, name: String?}} account - The account to add the new Name to + * @param {Account} account - The account to add the new Name to * @param {String} name - The name to set */ export async function setAccountContactName(account, name) { const cDB = await Database.getInstance(); + // Set the name + account.name = name; + // Save name to the DB - await cDB.addAccount({ - publicKey: account.publicKey, - encWif: account.encWif, - localProposals: account?.localProposals || [], - contacts: account?.contacts || [], - name: name, - }); + await cDB.updateAccount(account); } /** @@ -597,8 +557,8 @@ export async function guiAddContact() { const cAccount = await cDB.getAccount(); // Check this Contact isn't already saved, either fully or partially - const cContactByName = getContactBy(cAccount, { name: strName }); - const cContactByPubkey = getContactBy(cAccount, { pubkey: strAddr }); + const cContactByName = cAccount.getContactBy({ name: strName }); + const cContactByPubkey = cAccount.getContactBy({ pubkey: strAddr }); // If both Name and Key are saved, then they just tried re-adding the same Contact twice if (cContactByName && cContactByPubkey) { @@ -698,8 +658,8 @@ export async function guiAddContactPrompt( const cAccount = await cDB.getAccount(); // Check this Contact isn't already saved, either fully or partially - const cContactByName = getContactBy(cAccount, { name: strName }); - const cContactByPubkey = getContactBy(cAccount, { pubkey: strPubkey }); + const cContactByName = cAccount.getContactBy({ name: strName }); + const cContactByPubkey = cAccount.getContactBy({ pubkey: strPubkey }); // If both Name and Key are saved, then they just tried re-adding the same Contact twice if (cContactByName && cContactByPubkey) { @@ -813,7 +773,7 @@ export async function guiEditContactNamePrompt(nIndex) { } // Check this new Name isn't already saved - const cContactByNewName = getContactBy(cAccount, { name: strNewName }); + const cContactByNewName = cAccount.getContactBy({ name: strNewName }); if (cContactByNewName) { createAlert( 'warning', @@ -828,13 +788,7 @@ export async function guiEditContactNamePrompt(nIndex) { cContact.label = strNewName; // Commit to DB - await cDB.addAccount({ - publicKey: cAccount.publicKey, - encWif: cAccount.encWif, - localProposals: cAccount?.localProposals || [], - contacts: cAccount?.contacts || [], - name: cAccount?.name, - }); + await cDB.updateAccount(cAccount); // Re-render the Contacts UI await renderContacts(cAccount); @@ -863,13 +817,7 @@ export async function guiAddContactImage(nIndex) { cContact.icon = strImage; // Commit to DB - await cDB.addAccount({ - publicKey: cAccount.publicKey, - encWif: cAccount.encWif, - localProposals: cAccount?.localProposals || [], - contacts: cAccount?.contacts || [], - name: cAccount?.name, - }); + await cDB.updateAccount(cAccount); // Re-render the Contacts UI await renderContacts(cAccount); @@ -1003,7 +951,7 @@ export async function guiCheckRecipientInput(event) { const cAccount = await cDB.getAccount(); // Check if this is a Contact - const cContact = getContactBy(cAccount, { + const cContact = cAccount?.getContactBy({ name: strInput, pubkey: strInput, }); @@ -1024,7 +972,7 @@ export async function guiCheckRecipientInput(event) { /** * Search for a Name of a Contact from a given Account and Address - * @param {object} cAccount - The Account to search for the Contact + * @param {Account} cAccount - The Account to search for the Contact * @param {string} address - The address to search for a Contact with * @returns {string} - The Name of the address Contact, or just the address if none is found */ @@ -1036,8 +984,8 @@ export function getNameOrAddress(cAccount, address) { /** * Convert the current Account's Contact to a Share URI - * @param {object?} - An optional Account to construct the Contact URI from, if omitted, the current DB account is used - * @param {string?} - An optional Master Public Key to attach to the Contact URI + * @param {Account?} account - An optional Account to construct the Contact URI from, if omitted, the current DB account is used + * @param {string?} pubkey - An optional Master Public Key to attach to the Contact URI */ export async function localContactToURI(account, pubkey) { // Fetch the current Account diff --git a/scripts/database.js b/scripts/database.js index ab049a2f9..9bc6e47ca 100644 --- a/scripts/database.js +++ b/scripts/database.js @@ -2,10 +2,16 @@ import { openDB, IDBPDatabase } from 'idb'; import Masternode from './masternode.js'; import { Settings } from './settings.js'; import { cChainParams } from './chain_params.js'; -import { confirmPopup, sanitizeHTML, createAlert } from './misc.js'; +import { + confirmPopup, + sanitizeHTML, + createAlert, + isSameType, + isEmpty, +} from './misc.js'; import { PromoWallet } from './promos.js'; import { ALERTS, translation } from './i18n.js'; -import { Contact } from './contacts-book.js'; +import { Account } from './accounts.js'; /** The current version of the DB - increasing this will prompt the Upgrade process for clients with an older version */ export const DB_VERSION = 2; @@ -82,33 +88,133 @@ export class Database { /** * Adds an account to the database - * @param {Object} o - * @param {String} o.publicKey - Public key associated to the account. Can be an xpub - * @param {String} o.encWif - Encrypted private key associated to the account - * @param {Array} o.localProposals - Local proposals awaiting to be finalized - * @param {Array} o.contacts - Contacts for the account's contact book - * @param {String} o.name - The local Contact name for the account + * + * This will also apply missing Account keys from the Account class automatically, and check high-level type safety. + * @param {Account} account - The Account to add */ - async addAccount({ - publicKey, - encWif, - localProposals = [], - contacts = [], - name = '', - }) { - const oldAccount = await this.getAccount(); - const newAccount = { - publicKey, - encWif, - localProposals, - contacts, - name, - }; + async addAccount(account) { + // Critical: Ensure the input is an Account instance + if (!(account instanceof Account)) { + console.error( + '---- addAccount() called with invalid input, input dump below ----' + ); + console.error(account); + console.error('---- end of account dump ----'); + createAlert( + 'warning', + 'Account Creation Error
Logs were dumped in your Browser Console
Please submit these privately to PIVX Labs Developers!' + ); + return false; + } + + // Create an empty DB Account + const cDBAccount = new Account(); + + // We'll overlay the `account` keys atop the `DB Account` keys: + // Note: Since the Account constructor defaults all properties to type-safe defaults, we can already assume `cDBAccount` is safe. + // Note: Since `addAccount` could be called with *anything*, we must apply the same type-safety on it's input. + for (const strKey of Object.keys(cDBAccount)) { + // Ensure the Type is correct for the Key against the Account class + if (!isSameType(account[strKey], cDBAccount[strKey])) { + console.error( + 'DB: addAccount() key "' + + strKey + + '" does NOT match the correct class type, likely data mismatch, please report!' + ); + continue; + } + + // Overlay the 'new' keys on top of the DB keys + cDBAccount[strKey] = account[strKey]; + } + const store = this.#db .transaction('accounts', 'readwrite') .objectStore('accounts'); + + // Check this account isn't already added (by pubkey once multi-account) + if (await store.get('account')) + return console.error( + 'DB: Ran addAccount() when account already exists!' + ); + // When the account system is going to be added, the key is gonna be the publicKey - await store.put({ ...oldAccount, ...newAccount }, 'account'); + await store.put(cDBAccount, 'account'); + } + + /** + * Update specified keys for an Account in the DB. + * + * This will also apply new Account keys from MPW updates automatically, and check high-level type safety. + * + * --- + * + * To allow "deleting/clearing/resetting" keys, for example, when removing Proposals or Contacts, toggle `allowDeletion`. + * + * **Do NOT toggle unless otherwise necessary**, to avoid overwriting keys from code errors or misuse. + * @param {Account} account - The Account to update, with new data inside + * @param {boolean} allowDeletion - Allow setting keys to an "empty" state (`""`, `[]`, `{}`) + */ + async updateAccount(account, allowDeletion = false) { + // Critical: Ensure the input is an Account instance + if (!(account instanceof Account)) { + console.error( + '---- updateAccount() called with invalid input, input dump below ----' + ); + console.error(account); + console.error('---- end of account dump ----'); + createAlert( + 'warning', + 'DB Update Error
Your wallet is safe, logs were dumped in your Browser Console
Please submit these privately to PIVX Labs Developers!' + ); + return false; + } + + // Fetch the DB account + const cDBAccount = await this.getAccount(); + + // If none exists; we should throw an error, as there's no reason for MPW to call `updateAccount` before an account was added using `addAccount` + // Note: This is mainly to force "good standards" in which we don't lazily use `updateAccount` to create NEW accounts. + if (!cDBAccount) { + console.error( + '---- updateAccount() called without an account existing, input dump below ----' + ); + console.error(account); + console.error('---- end of input dump ----'); + createAlert( + 'warning', + 'DB Update Error
Logs were dumped in your Browser Console
Please submit these privately to PIVX Labs Developers!' + ); + return false; + } + + // We'll overlay the `account` keys atop the `DB Account` keys: + // Note: Since `getAccount` already checks type-safety, we can already assume `cDBAccount` is safe. + // Note: Since `updateAccount` could be called with *anything*, we must apply the same type-safety on it's input. + for (const strKey of Object.keys(cDBAccount)) { + // Ensure the Type is correct for the Key against the Account class + if (!isSameType(account[strKey], cDBAccount[strKey])) { + console.error( + 'DB: updateAccount() key "' + + strKey + + '" does NOT match the correct class type, likely data mismatch, please report!' + ); + continue; + } + + // Ensure the 'updated' key (which may not exist) is NOT a default or EMPTY value + // Note: this can be overriden manually when erasing data such as Contacts, Local Proposals, etc. + if (!allowDeletion && isEmpty(account[strKey])) continue; + + // Overlay the 'new' keys on top of the DB keys + cDBAccount[strKey] = account[strKey]; + } + + const store = this.#db + .transaction('accounts', 'readwrite') + .objectStore('accounts'); + // When the account system is going to be added, the key is gonna be the publicKey + await store.put(cDBAccount, 'account'); } /** @@ -125,14 +231,39 @@ export class Database { } /** - * Gets an account from the database - * @returns {Promise<{publicKey: String, encWif: String?, localProposals: Array, contacts: Array, name: String?}?>} + * Gets an account from the database. + * + * This also will apply new keys from MPW updates automatically, and check high-level type safety. + * @returns {Promise} */ async getAccount() { const store = this.#db .transaction('accounts', 'readonly') .objectStore('accounts'); - return await store.get('account'); + const cDBAccount = await store.get('account'); + + // If there's no DB Account, we'll return null early + if (!cDBAccount) return null; + + // We'll generate an Account Class for up-to-date keys, then layer the 'new' type-checked properties on it one-by-one + const cAccount = new Account(); + for (const strKey of Object.keys(cAccount)) { + // Ensure the Type is correct for the Key against the Account class (with instanceof to also check Class validity) + if (!isSameType(cDBAccount[strKey], cAccount[strKey])) { + console.error( + 'DB: getAccount() key "' + + strKey + + '" does NOT match the correct class type, likely bad data saved, please report!' + ); + continue; + } + + // Overlay the 'DB' keys on top of the Class Instance keys + cAccount[strKey] = cDBAccount[strKey]; + } + + // Return the Account Class + return cAccount; } /** @@ -214,11 +345,16 @@ export class Database { const localProposals = JSON.parse( localStorage.localProposals || '[]' ); - await this.addAccount({ + + // Update and format the old Account data + const cAccount = new Account({ publicKey: localStorage.publicKey, encWif: localStorage.encwif, - localProposals, + localProposals: localProposals, }); + + // Migrate the old Account data to the new DB + await this.addAccount(cAccount); } catch (e) { console.error(e); createAlert('warning', ALERTS.MIGRATION_ACCOUNT_FAILURE); diff --git a/scripts/global.js b/scripts/global.js index 972f1fd19..266a7c48e 100644 --- a/scripts/global.js +++ b/scripts/global.js @@ -56,6 +56,7 @@ import { guiToggleReceiveType, } from './contacts-book.js'; import { Buffer } from 'buffer'; +import { Account } from './accounts.js'; /** A flag showing if base MPW is fully loaded or not */ export let fIsLoaded = false; @@ -840,6 +841,9 @@ export async function createActivityListHTML(arrTXs, fRewards = false) { // Generate the TX list for (const cTx of arrTXs) { + // If no account is loaded, we render nothing! + if (!masterKey) break; + const dateTime = new Date(cTx.time * 1000); // If this Tx is older than 24h, then hit the `Date` cache logic, otherwise, use a `Time` and skip it @@ -1272,17 +1276,21 @@ export function guiPreparePayment(strTo = '', nAmount = 0, strDesc = '') { } } -export function hideAllWalletOptions() { - // Hide and Reset the Vanity address input +/** + * Set the "Wallet Options" menu visibility + * @param {String} strDisplayCSS - The `display` CSS option to set the Wallet Options to + */ +export function setDisplayForAllWalletOptions(strDisplayCSS) { + // Set the display and Reset the Vanity address input doms.domPrefix.value = ''; - doms.domPrefix.style.display = 'none'; - - // Hide all "*Wallet" buttons - doms.domGenerateWallet.style.display = 'none'; - doms.domImportWallet.style.display = 'none'; - doms.domGenVanityWallet.style.display = 'none'; - doms.domAccessWallet.style.display = 'none'; - doms.domGenHardwareWallet.style.display = 'none'; + doms.domPrefix.style.display = strDisplayCSS; + + // Set all "*Wallet" buttons + doms.domGenerateWallet.style.display = strDisplayCSS; + doms.domImportWallet.style.display = strDisplayCSS; + doms.domGenVanityWallet.style.display = strDisplayCSS; + doms.domAccessWallet.style.display = strDisplayCSS; + doms.domGenHardwareWallet.style.display = strDisplayCSS; } export async function govVote(hash, voteCode) { @@ -1545,13 +1553,19 @@ export async function guiImportWallet() { // Save the public key to disk for future View Only mode post-decryption fSavePublicKey: true, }); - const database = await Database.getInstance(); + if (masterKey) { - database.addAccount({ + // Prepare a new Account to add + const cAccount = new Account({ publicKey: await masterKey.keyToExport, encWif: strPrivKey, }); + + // Add the new Account to the DB + const database = await Database.getInstance(); + database.addAccount(cAccount); } + // Destroy residue import data doms.domPrivKey.value = ''; doms.domPrivKeyPassword.value = ''; @@ -1562,19 +1576,10 @@ export async function guiImportWallet() { const fHasWallet = await decryptWallet(doms.domPrivKey.value); // If the wallet was successfully loaded, hide all options and load the dash! - if (fHasWallet) hideAllWalletOptions(); + if (fHasWallet) setDisplayForAllWalletOptions('none'); } export async function guiEncryptWallet() { - // Disable wallet encryption in testnet mode - if (cChainParams.current.isTestnet) - return createAlert( - 'warning', - ALERTS.TESTNET_ENCRYPTION_DISABLED, - [], - 2500 - ); - // Fetch our inputs, ensure they're of decent entropy + match eachother const strPass = doms.domEncryptPasswordFirst.value, strPassRetype = doms.domEncryptPasswordSecond.value; @@ -2222,26 +2227,21 @@ async function renderProposals(arrProposals, fContested) { ); const deleteProposal = async () => { - // Fetch account and its localProposals array (default to [] if none exists) - const { localProposals = [] } = - await database.getAccount(); + // Fetch Account + const account = await database.getAccount(); - // Find index of proposal to remove - const nProposalIndex = localProposals.findIndex( + // Find index of Account local proposal to remove + const nProposalIndex = account.localProposals.findIndex( (p) => p.txid === cProposal.mpw.txid ); // If found, remove the proposal and update the account with the modified localProposals array if (nProposalIndex > -1) { - const account = await database.getAccount(); - localProposals.splice(nProposalIndex, 1); - await database.addAccount({ - publicKey: account.publicKey, - encWif: account.encWif, - localProposals, - contacts: account?.contacts || [], - name: account?.name || '', - }); + // Remove our proposal from it + account.localProposals.splice(nProposalIndex, 1); + + // Update the DB + await database.updateAccount(account, true); } }; @@ -2779,16 +2779,13 @@ export async function createProposal() { if (ok) { proposal.txid = txid; const database = await Database.getInstance(); + + // Fetch our Account, add the proposal to it const account = await database.getAccount(); - const localProposals = account?.localProposals || []; - localProposals.push(proposal); - await database.addAccount({ - publicKey: account.publicKey, - encWif: account.encWif, - localProposals, - contacts: account?.contacts || [], - name: account?.name || '', - }); + account.localProposals.push(proposal); + + // Update the DB + await database.updateAccount(account); createAlert('success', translation.PROPOSAL_CREATED, [], 7500); updateGovernanceTab(); } @@ -2820,8 +2817,7 @@ export function refreshChainData() { export const beforeUnloadListener = (evt) => { evt.preventDefault(); // Disable Save your wallet warning on unload - if (!cChainParams.current.isTestnet) - createAlert('warning', ALERTS.SAVE_WALLET_PLEASE, [], 10000); + createAlert('warning', ALERTS.SAVE_WALLET_PLEASE, [], 10000); // Most browsers ignore this nowadays, but still, keep it 'just incase' return (evt.returnValue = translation.BACKUP_OR_ENCRYPT_WALLET); }; diff --git a/scripts/misc.js b/scripts/misc.js index d3c398395..53b43e06a 100644 --- a/scripts/misc.js +++ b/scripts/misc.js @@ -433,6 +433,44 @@ export function isBase64(str) { return true; } +/** + * Checks if two values are of the same type. + * + * @param {any} a - The first value. + * @param {any} b - The second value. + * @returns {boolean} - `true` if the values are of the same type, `false` if not or if there was an error comparing. + */ +export function isSameType(a, b) { + try { + if (a === null || b === null) return a === b; + if (typeof a !== typeof b) return false; + if (typeof a === 'object') + return Object.getPrototypeOf(a) === Object.getPrototypeOf(b); + return true; + } catch (e) { + return false; + } +} + +/** + * Checks if a value is 'empty'. + * + * @param {any} val - The value to check. + * @returns {boolean} - `true` if the value is 'empty', `false` otherwise. + * --- + * Values **considered 'empty'**: `null`, `undefined`, empty strings, empty arrays, empty objects. + * + * Values **NOT considered 'empty'**: Any non-nullish primitive value: numbers (including `0` and `NaN`), `true`, `false`, `Symbol()`, `BigInt()`. + */ +export function isEmpty(val) { + return ( + val == null || + val === '' || + (Array.isArray(val) && val.length === 0) || + (typeof val === 'object' && Object.keys(val).length === 0) + ); +} + /** * An artificial sleep function to pause code execution * diff --git a/scripts/network.js b/scripts/network.js index 96a78b6a9..c3a5f5da1 100644 --- a/scripts/network.js +++ b/scripts/network.js @@ -596,7 +596,7 @@ export class ExplorerNetwork extends Network { // If the public Master Key (xpub, address...) is different, then wipe TX history if ( (await this.masterKey?.keyToExport) !== - (await masterKey.keyToExport) + (await masterKey?.keyToExport) ) { this.arrTxHistory = []; } diff --git a/scripts/settings.js b/scripts/settings.js index 0132d069a..74c44eaaf 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -2,14 +2,22 @@ import { doms, getBalance, getStakingBalance, + mempool, refreshChainData, + setDisplayForAllWalletOptions, updateActivityGUI, + updateEncryptionGUI, updateGovernanceTab, } from './global.js'; -import { fWalletLoaded, masterKey } from './wallet.js'; +import { + hasEncryptedWallet, + importWallet, + masterKey, + setMasterKey, +} from './wallet.js'; import { cChainParams } from './chain_params.js'; import { setNetwork, ExplorerNetwork, getNetwork } from './network.js'; -import { createAlert } from './misc.js'; +import { confirmPopup, createAlert } from './misc.js'; import { switchTranslation, ALERTS, @@ -426,20 +434,50 @@ async function setAnalytics(level, fSilent = false) { ); } -export function toggleTestnet() { - if (fWalletLoaded) { - // Revert testnet toggle - doms.domTestnetToggler.checked = !doms.domTestnetToggler.checked; - return createAlert('warning', ALERTS.UNABLE_SWITCH_TESTNET, [], 3250); +/** + * Toggle between Mainnet and Testnet + */ +export async function toggleTestnet() { + const cNextNetwork = cChainParams.current.isTestnet + ? cChainParams.main + : cChainParams.testnet; + + // If the current wallet is not saved, we'll ask the user for confirmation, since they'll lose their wallet if they switch with an unsaved wallet! + if (masterKey && !(await hasEncryptedWallet())) { + const fContinue = await confirmPopup({ + title: translation.netSwitchUnsavedWarningTitle.replace( + '{network}', + cChainParams.current.name + ), + html: ` + ${translation.netSwitchUnsavedWarningSubtitle.replace( + '{network}', + cChainParams.current.name + )} +
+ ${translation.netSwitchUnsavedWarningSubtext.replace( + '{network}', + cNextNetwork.name + )} +
+
+ ${ + translation.netSwitchUnsavedWarningConfirmation + } + `, + }); + + if (!fContinue) { + // Kick back the "toggle" switch + doms.domTestnetToggler.checked = cChainParams.current.isTestnet; + return; + } } // Update current chain config - cChainParams.current = cChainParams.current.isTestnet - ? cChainParams.main - : cChainParams.testnet; + cChainParams.current = cNextNetwork; // Update UI and static tickers - //TRANSLATIONS doms.domTestnet.style.display = cChainParams.current.isTestnet ? '' : 'none'; @@ -451,12 +489,47 @@ export function toggleTestnet() { // Update testnet toggle in settings doms.domTestnetToggler.checked = cChainParams.current.isTestnet; - fillExplorerSelect(); - fillNodeSelect(); + // Check if the new network has an Account + const cNewDB = await Database.getInstance(); + const cNewAccount = await cNewDB.getAccount(); + if (cNewAccount?.publicKey) { + // Import the new wallet (overwriting the existing in-memory wallet) + await importWallet({ newWif: cNewAccount.publicKey }); + } else { + // Nuke the Master Key + setMasterKey(null); + + // Hide all Dashboard info, kick the user back to the "Getting Started" area + doms.domGenKeyWarning.style.display = 'none'; + doms.domGuiWallet.style.display = 'none'; + doms.domWipeWallet.hidden = true; + doms.domRestoreWallet.hidden = true; + + // Set the "Wallet Options" display CSS to it's Default + setDisplayForAllWalletOptions(''); + + // Reset the "Vanity" and "Import" flows + doms.domPrefix.value = ''; + doms.domPrefix.style.display = 'none'; + + // Show "Access Wallet" button + doms.domImportWallet.style.display = 'none'; + doms.domPrivKey.style.opacity = '0'; + doms.domAccessWallet.style.display = ''; + doms.domAccessWalletBtn.style.display = ''; + + // Hide "Import Wallet" so the user has to follow the `accessOrImportWallet()` flow + doms.domImportWallet.style.display = 'none'; + } + + mempool.UTXOs = []; getBalance(true); getStakingBalance(true); - updateActivityGUI(); - updateGovernanceTab(); + await updateEncryptionGUI(!!masterKey); + await fillExplorerSelect(); + await fillNodeSelect(); + await updateActivityGUI(); + await updateGovernanceTab(); } export function toggleDebug() { diff --git a/scripts/transactions.js b/scripts/transactions.js index e4d1bafc5..4a9dcf378 100644 --- a/scripts/transactions.js +++ b/scripts/transactions.js @@ -31,7 +31,6 @@ import { } from './misc.js'; import { bytesToHex, hexToBytes, dSHA256 } from './utils.js'; import { Database } from './database.js'; -import { getContactBy } from './contacts-book.js'; function validateAmount(nAmountSats, nMinSats = 10000) { // Validate the amount is a valid number, and meets the minimum (if any) @@ -92,7 +91,9 @@ export async function createTxGUI() { // Check for any contacts that match the input const cDB = await Database.getInstance(); const cAccount = await cDB.getAccount(); - const cContact = getContactBy(cAccount, { + + // If we have an Account, then check our Contacts for anything matching too + const cContact = cAccount?.getContactBy({ name: strRawReceiver, pubkey: strRawReceiver, }); diff --git a/scripts/wallet.js b/scripts/wallet.js index e67e3845c..d40158704 100644 --- a/scripts/wallet.js +++ b/scripts/wallet.js @@ -19,7 +19,7 @@ import { } from './misc.js'; import { refreshChainData, - hideAllWalletOptions, + setDisplayForAllWalletOptions, getBalance, getStakingBalance, } from './global.js'; @@ -37,6 +37,7 @@ import createXpub from 'create-xpub'; import * as jdenticon from 'jdenticon'; import { Database } from './database.js'; import { guiRenderCurrentReceiveModal } from './contacts-book.js'; +import { Account } from './accounts.js'; export let fWalletLoaded = false; @@ -267,7 +268,10 @@ export class HdMasterKey extends MasterKey { if (this._isViewOnly) return this._hdKey.publicExtendedKey; // We need the xpub to point at the account level return this._hdKey.derive( - getDerivationPath(false).split('/').slice(0, 4).join('/') + getDerivationPath(false, 0, 0, 0, false) + .split('/') + .slice(0, 4) + .join('/') ).publicExtendedKey; } } @@ -593,7 +597,6 @@ export function deriveAddress({ pkBytes, publicKey, output = 'ENCODED' }) { * @param {string | Array} options.newWif - The import data (if omitted, the UI input is accessed) * @param {boolean} options.fRaw - Whether the import data is raw bytes or encoded (WIF, xpriv, seed) * @param {boolean} options.isHardwareWallet - Whether the import is from a Hardware wallet or not - * @param {boolean} options.skipConfirmation - Whether to skip the import UI confirmation or not * @param {boolean} options.fSavePublicKey - Whether to save the derived public key to disk (for View Only mode) * @param {boolean} options.fStartup - Whether the import is at Startup or at Runtime * @returns {Promise} @@ -602,17 +605,12 @@ export async function importWallet({ newWif = false, fRaw = false, isHardwareWallet = false, - skipConfirmation = false, fSavePublicKey = false, fStartup = false, } = {}) { - const strImportConfirm = - "Do you really want to import a new address? If you haven't saved the last private key, the wallet will be LOST forever."; - const walletConfirm = - fWalletLoaded && !skipConfirmation - ? await confirmPopup({ html: strImportConfirm }) - : true; - + // TODO: remove `walletConfirm`, it is useless as Accounts cannot be overriden, and multi-accounts will come soon anyway + // ... just didn't want to add a huge whitespace change from removing the `if (walletConfirm) {` line + const walletConfirm = true; if (walletConfirm) { if (isHardwareWallet) { // Firefox does NOT support WebUSB, thus cannot work with Hardware wallets out-of-the-box @@ -732,15 +730,9 @@ export async function importWallet({ ); jdenticon.update('#identicon'); - // Hide the encryption prompt if the user is in Testnet mode - // ... or is using a hardware wallet, or is view-only mode. - if ( - !( - cChainParams.current.isTestnet || - isHardwareWallet || - masterKey.isViewOnly - ) - ) { + // Hide the encryption prompt if the user is using + // a hardware wallet, or is view-only mode. + if (!(isHardwareWallet || masterKey.isViewOnly)) { if ( // If the wallet was internally imported (not UI pasted), like via vanity, display the encryption prompt (((fRaw && newWif.length) || newWif) && @@ -753,6 +745,9 @@ export async function importWallet({ // If the wallet was pasted and is an encrypted import, display the lock wallet UI doms.domWipeWallet.hidden = false; } + } else { + // Hide the encryption UI + doms.domGenKeyWarning.style.display = 'none'; } // Fetch state from explorer, if this import was post-startup @@ -762,7 +757,7 @@ export async function importWallet({ } // Hide all wallet starter options - hideAllWalletOptions(); + setDisplayForAllWalletOptions('none'); } } @@ -770,7 +765,7 @@ export async function importWallet({ * Set or replace the active Master Key with a new Master Key * @param {MasterKey} mk - The new Master Key to set active */ -async function setMasterKey(mk) { +export async function setMasterKey(mk) { masterKey = mk; // Update the network master key await getNetwork().setMasterKey(masterKey); @@ -778,12 +773,9 @@ async function setMasterKey(mk) { // Wallet Generation export async function generateWallet(noUI = false) { - const strImportConfirm = - "Do you really want to import a new address? If you haven't saved the last private key, the wallet will be LOST forever."; - const walletConfirm = - fWalletLoaded && !noUI - ? await confirmPopup({ html: strImportConfirm }) - : true; + // TODO: remove `walletConfirm`, it is useless as Accounts cannot be overriden, and multi-accounts will come soon anyway + // ... just didn't want to add a huge whitespace change from removing the `if (walletConfirm) {` line + const walletConfirm = true; if (walletConfirm) { const mnemonic = generateMnemonic(); @@ -796,8 +788,7 @@ export async function generateWallet(noUI = false) { await setMasterKey(new HdMasterKey({ seed })); fWalletLoaded = true; - if (!cChainParams.current.isTestnet) - doms.domGenKeyWarning.style.display = 'block'; + doms.domGenKeyWarning.style.display = 'block'; // Add a listener to block page unloads until we are sure the user has saved their keys, safety first! addEventListener('beforeunload', beforeUnloadListener, { capture: true, @@ -805,7 +796,7 @@ export async function generateWallet(noUI = false) { // Display the dashboard doms.domGuiWallet.style.display = 'block'; - hideAllWalletOptions(); + setDisplayForAllWalletOptions('none'); // Update identicon doms.domIdenticon.dataset.jdenticonValue = masterKey.getAddress( @@ -875,11 +866,22 @@ export async function encryptWallet(strPassword = '') { // Hide the encryption warning doms.domGenKeyWarning.style.display = 'none'; - const database = await Database.getInstance(); - database.addAccount({ + // Prepare to Add/Update an account in the DB + const cAccount = new Account({ publicKey: await masterKey.keyToExport, encWif: strEncWIF, }); + + // Incase of a "Change Password", we check if an Account already exists + const database = await Database.getInstance(); + if (await database.getAccount()) { + // Update the existing Account (new encWif) in the DB + await database.updateAccount(cAccount); + } else { + // Add the new Account to the DB + await database.addAccount(cAccount); + } + // Remove the exit blocker, we can annoy the user less knowing the key is safe in their database! removeEventListener('beforeunload', beforeUnloadListener, { capture: true, @@ -900,7 +902,6 @@ export async function decryptWallet(strPassword = '') { } else { await importWallet({ newWif: strDecWIF, - skipConfirmation: true, // Save the public key to disk for View Only mode fSavePublicKey: true, });