diff --git a/web/cashtab/src/hooks/__mocks__/mockLegacyWallets.js b/web/cashtab/src/hooks/__mocks__/mockLegacyWallets.js index c142dc9cc..9154cc7b9 100644 --- a/web/cashtab/src/hooks/__mocks__/mockLegacyWallets.js +++ b/web/cashtab/src/hooks/__mocks__/mockLegacyWallets.js @@ -1,115 +1,127 @@ export default { legacyAlphaMainnet: { mnemonic: 'apart vacuum color cream drama kind foil history hurt alone ask census', name: 'MigrationTestAlpha on Mainnet', Path245: { cashAddress: 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298', slpAddress: 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG', fundingAddress: 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP', }, Path145: { cashAddress: 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z', slpAddress: 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt', fundingAddress: 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF', }, }, migratedLegacyAlphaMainnet: { mnemonic: 'apart vacuum color cream drama kind foil history hurt alone ask census', name: 'MigrationTestAlpha on Mainnet', Path245: { cashAddress: 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298', slpAddress: 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG', fundingAddress: 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP', + publicKey: + '03c4a69fd90c8b196683216cffd2943a7b13b0db0812e44a4ff156ac7e03fc4ed7', }, Path145: { cashAddress: 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z', slpAddress: 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt', fundingAddress: 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF', + publicKey: + '02fe5308d77bcce825068a9e46adc6f032dbbe39167a7b6d05ac563ac71d8b186e', }, Path1899: { cashAddress: 'bitcoincash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cptzgcqy6', slpAddress: 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', fundingWif: 'Kx4FiBMvKK1iXjFk5QTaAK6E4mDGPjmwDZ2HDKGUZpE4gCXMaPe9', fundingAddress: 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', legacyAddress: '1J1Aq5tAAYxZgSDRo8soKM2Rb41z3xrYpm', + publicKey: + '02a06bb380cf180d703f6f80796a13555aefff817d1f6f842f1e5c555b15f0fa70', }, }, legacyAlphaTestnet: { mnemonic: 'apart vacuum color cream drama kind foil history hurt alone ask census', name: 'MigrationTestAlpha on Testnet', Path245: { cashAddress: 'bchtest:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy539jyxazm', fundingAddress: 'slptest:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5234lu2sx', fundingWif: 'cN3NDtiabeeX1Wzg2CPgLgrb7fsDG8PgWqTnmwAhYWmY6osSta7Q', legacyAddress: 'muCLmiGfH961UuWrTTFNU3KxH9pVQJJx6Z', slpAddress: 'slptest:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5234lu2sx', }, Path145: { cashAddress: 'bchtest:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vtdvpkdz7', fundingAddress: 'slptest:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vset6v6sr', fundingWif: 'cTKuvZ644Sf7xra5z3dPshEECJNaDyBCq7HyBsysQPh1ySSpxtQ1', legacyAddress: 'mjWxk74mLM7TieAsSJArLF7nXqC6oc2mof', slpAddress: 'slptest:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vset6v6sr', }, }, migratedLegacyAlphaTestnet: { mnemonic: 'apart vacuum color cream drama kind foil history hurt alone ask census', name: 'MigrationTestAlpha on Testnet', Path245: { cashAddress: 'bchtest:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy539jyxazm', fundingAddress: 'slptest:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5234lu2sx', fundingWif: 'cN3NDtiabeeX1Wzg2CPgLgrb7fsDG8PgWqTnmwAhYWmY6osSta7Q', legacyAddress: 'muCLmiGfH961UuWrTTFNU3KxH9pVQJJx6Z', slpAddress: 'slptest:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5234lu2sx', + publicKey: + '03c4a69fd90c8b196683216cffd2943a7b13b0db0812e44a4ff156ac7e03fc4ed7', }, Path145: { cashAddress: 'bchtest:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vtdvpkdz7', fundingAddress: 'slptest:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vset6v6sr', fundingWif: 'cTKuvZ644Sf7xra5z3dPshEECJNaDyBCq7HyBsysQPh1ySSpxtQ1', legacyAddress: 'mjWxk74mLM7TieAsSJArLF7nXqC6oc2mof', slpAddress: 'slptest:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vset6v6sr', + publicKey: + '02fe5308d77bcce825068a9e46adc6f032dbbe39167a7b6d05ac563ac71d8b186e', }, Path1899: { cashAddress: 'bchtest:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7c9ex06hrx', fundingAddress: 'slptest:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7c7dp5qq3m', fundingWif: 'cNRFB6MmkNhyhAj1TpGhXdbHgzWg4BsdHbAkKjiz4vt4vwgpC44F', legacyAddress: 'mxX888y8yaPpTYh3WhrB9GEkT3cgumYwPw', slpAddress: 'slptest:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7c7dp5qq3m', + publicKey: + '02a06bb380cf180d703f6f80796a13555aefff817d1f6f842f1e5c555b15f0fa70', }, }, }; diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js index 1b5703a85..6631984d4 100644 --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -1,1099 +1,1130 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { useState, useEffect } from 'react'; import useAsyncTimeout from '@hooks/useAsyncTimeout'; import usePrevious from '@hooks/usePrevious'; import useBCH from '@hooks/useBCH'; import BigNumber from 'bignumber.js'; import { fromSmallestDenomination, loadStoredWallet, isValidStoredWallet, + isLegacyMigrationRequired, } from '@utils/cashMethods'; import { isValidCashtabSettings } from '@utils/validation'; import localforage from 'localforage'; import { currency } from '@components/Common/Ticker'; import isEmpty from 'lodash.isempty'; import isEqual from 'lodash.isequal'; import { xecReceivedNotification, eTokenReceivedNotification, } from '@components/Common/Notifications'; const useWallet = () => { const [wallet, setWallet] = useState(false); const [cashtabSettings, setCashtabSettings] = useState(false); const [fiatPrice, setFiatPrice] = useState(null); const [apiError, setApiError] = useState(false); const [checkFiatInterval, setCheckFiatInterval] = useState(null); const { getBCH, getUtxos, getHydratedUtxoDetails, getSlpBalancesAndUtxos, getTxHistory, getTxData, addTokenTxData, } = useBCH(); const [loading, setLoading] = useState(true); const [apiIndex, setApiIndex] = useState(0); const [BCH, setBCH] = useState(getBCH(apiIndex)); const { balances, tokens, utxos } = isValidStoredWallet(wallet) ? wallet.state : { balances: {}, tokens: [], utxos: null, }; const previousBalances = usePrevious(balances); const previousTokens = usePrevious(tokens); const previousWallet = usePrevious(wallet); const previousUtxos = usePrevious(utxos); // If you catch API errors, call this function const tryNextAPI = () => { let currentApiIndex = apiIndex; // How many APIs do you have? const apiString = process.env.REACT_APP_BCHA_APIS; const apiArray = apiString.split(','); console.log(`You have ${apiArray.length} APIs to choose from`); console.log(`Current selection: ${apiIndex}`); // If only one, exit if (apiArray.length === 0) { console.log( `There are no backup APIs, you are stuck with this error`, ); return; } else if (currentApiIndex < apiArray.length - 1) { currentApiIndex += 1; console.log( `Incrementing API index from ${apiIndex} to ${currentApiIndex}`, ); } else { // Otherwise use the first option again console.log(`Retrying first API index`); currentApiIndex = 0; } //return setApiIndex(currentApiIndex); console.log(`Setting Api Index to ${currentApiIndex}`); setApiIndex(currentApiIndex); return setBCH(getBCH(currentApiIndex)); // If you have more than one, use the next one // If you are at the "end" of the array, use the first one }; const normalizeSlpBalancesAndUtxos = (slpBalancesAndUtxos, wallet) => { const Accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; slpBalancesAndUtxos.nonSlpUtxos.forEach(utxo => { const derivatedAccount = Accounts.find( account => account.cashAddress === utxo.address, ); utxo.wif = derivatedAccount.fundingWif; }); return slpBalancesAndUtxos; }; const normalizeBalance = slpBalancesAndUtxos => { const totalBalanceInSatoshis = slpBalancesAndUtxos.nonSlpUtxos.reduce( (previousBalance, utxo) => previousBalance + utxo.value, 0, ); return { totalBalanceInSatoshis, totalBalance: fromSmallestDenomination(totalBalanceInSatoshis), }; }; const deriveAccount = async (BCH, { masterHDNode, path }) => { const node = BCH.HDNode.derivePath(masterHDNode, path); + const publicKey = BCH.HDNode.toPublicKey(node).toString('hex'); const cashAddress = BCH.HDNode.toCashAddress(node); const slpAddress = BCH.SLP.Address.toSLPAddress(cashAddress); return { + publicKey, cashAddress, slpAddress, fundingWif: BCH.HDNode.toWIF(node), fundingAddress: BCH.SLP.Address.toSLPAddress(cashAddress), legacyAddress: BCH.SLP.Address.toLegacyAddress(cashAddress), }; }; const loadWalletFromStorageOnStartup = async setWallet => { // get wallet object from localforage const wallet = await getWallet(); // If wallet object in storage is valid, use it to set state on startup if (isValidStoredWallet(wallet)) { // Convert all the token balance figures to big numbers const liveWalletState = loadStoredWallet(wallet.state); wallet.state = liveWalletState; setWallet(wallet); return setLoading(false); } // Loading will remain true until API calls populate this legacy wallet setWallet(wallet); }; const haveUtxosChanged = (wallet, utxos, previousUtxos) => { // Relevant points for this array comparing exercise // https://stackoverflow.com/questions/13757109/triple-equal-signs-return-false-for-arrays-in-javascript-why // https://stackoverflow.com/questions/7837456/how-to-compare-arrays-in-javascript // If this is initial state if (utxos === null) { // Then make sure to get slpBalancesAndUtxos return true; } // If this is the first time the wallet received utxos if (typeof utxos === 'undefined') { // Then they have certainly changed return true; } if (typeof previousUtxos === 'undefined') { // Compare to what you have in localStorage on startup // If previousUtxos are undefined, see if you have previousUtxos in wallet state // If you do, and it has everything you need, set wallet state with that instead of calling hydrateUtxos on all utxos if (isValidStoredWallet(wallet)) { // Convert all the token balance figures to big numbers const liveWalletState = loadStoredWallet(wallet.state); wallet.state = liveWalletState; return setWallet(wallet); } // If wallet in storage is a legacy wallet or otherwise does not have all state fields, // then assume utxos have changed return true; } // return true for empty array, since this means you definitely do not want to skip the next API call if (utxos && utxos.length === 0) { return true; } // If wallet is valid, compare what exists in written wallet state instead of former api call let utxosToCompare = previousUtxos; if (isValidStoredWallet(wallet)) { try { utxosToCompare = wallet.state.utxos; } catch (err) { console.log(`Error setting utxos to wallet.state.utxos`, err); console.log(`Wallet at err`, wallet); // If this happens, assume utxo set has changed return true; } } // Compare utxo sets return !isEqual(utxos, utxosToCompare); }; const update = async ({ wallet }) => { //console.log(`tick()`); //console.time("update"); try { if (!wallet) { return; } const cashAddresses = [ wallet.Path245.cashAddress, wallet.Path145.cashAddress, wallet.Path1899.cashAddress, ]; const utxos = await getUtxos(BCH, cashAddresses); // If an error is returned or utxos from only 1 address are returned if (!utxos || isEmpty(utxos) || utxos.error || utxos.length < 2) { // Throw error here to prevent more attempted api calls // as you are likely already at rate limits throw new Error('Error fetching utxos'); } // Need to call with wallet as a parameter rather than trusting it is in state, otherwise can sometimes get wallet=false from haveUtxosChanged const utxosHaveChanged = haveUtxosChanged( wallet, utxos, previousUtxos, ); // If the utxo set has not changed, if (!utxosHaveChanged) { // remove api error here; otherwise it will remain if recovering from a rate // limit error with an unchanged utxo set setApiError(false); // then wallet.state has not changed and does not need to be updated //console.timeEnd("update"); return; } const hydratedUtxoDetails = await getHydratedUtxoDetails( BCH, utxos, ); const slpBalancesAndUtxos = await getSlpBalancesAndUtxos( BCH, hydratedUtxoDetails, ); const txHistory = await getTxHistory(BCH, cashAddresses); const parsedTxHistory = await getTxData(BCH, txHistory); const parsedWithTokens = await addTokenTxData(BCH, parsedTxHistory); console.log(`slpBalancesAndUtxos`, slpBalancesAndUtxos); if (typeof slpBalancesAndUtxos === 'undefined') { console.log(`slpBalancesAndUtxos is undefined`); throw new Error('slpBalancesAndUtxos is undefined'); } const { tokens } = slpBalancesAndUtxos; const newState = { balances: {}, tokens: [], slpBalancesAndUtxos: [], }; newState.slpBalancesAndUtxos = normalizeSlpBalancesAndUtxos( slpBalancesAndUtxos, wallet, ); newState.balances = normalizeBalance(slpBalancesAndUtxos); newState.tokens = tokens; newState.parsedTxHistory = parsedWithTokens; newState.utxos = utxos; newState.hydratedUtxoDetails = hydratedUtxoDetails; // Set wallet with new state field wallet.state = newState; setWallet(wallet); // Write this state to indexedDb using localForage writeWalletState(wallet, newState); // If everything executed correctly, remove apiError setApiError(false); } catch (error) { console.log(`Error in update({wallet})`); console.log(error); // Set this in state so that transactions are disabled until the issue is resolved setApiError(true); //console.timeEnd("update"); // Try another endpoint console.log(`Trying next API...`); tryNextAPI(); } //console.timeEnd("update"); }; const getActiveWalletFromLocalForage = async () => { let wallet; try { wallet = await localforage.getItem('wallet'); } catch (err) { console.log(`Error in getActiveWalletFromLocalForage`, err); wallet = null; } return wallet; }; /* const getSavedWalletsFromLocalForage = async () => { let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log(`Error in getSavedWalletsFromLocalForage`, err); savedWallets = null; } return savedWallets; }; */ const getWallet = async () => { let wallet; let existingWallet; try { existingWallet = await getActiveWalletFromLocalForage(); // existing wallet will be // 1 - the 'wallet' value from localForage, if it exists // 2 - false if it does not exist in localForage // 3 - null if error // If the wallet does not have Path1899, add it - if (existingWallet && !existingWallet.Path1899) { - console.log(`Wallet does not have Path1899`); - existingWallet = await migrateLegacyWallet(BCH, existingWallet); + // or each Path1899, Path145, Path245 does not have a public key, add them + if (existingWallet) { + if (isLegacyMigrationRequired(existingWallet)) { + console.log( + `Wallet does not have Path1899 or does not have public key`, + ); + existingWallet = await migrateLegacyWallet( + BCH, + existingWallet, + ); + } } + // If not in localforage then existingWallet = false, check localstorage if (!existingWallet) { console.log(`no existing wallet, checking local storage`); existingWallet = JSON.parse( window.localStorage.getItem('wallet'), ); console.log(`existingWallet from localStorage`, existingWallet); // If you find it here, move it to indexedDb if (existingWallet !== null) { wallet = await getWalletDetails(existingWallet); await localforage.setItem('wallet', wallet); return wallet; } } } catch (err) { console.log(`Error in getWallet()`, err); /* Error here implies problem interacting with localForage or localStorage API Have not seen this error in testing In this case, you still want to return 'wallet' using the logic below based on the determination of 'existingWallet' from the logic above */ } if (existingWallet === null || !existingWallet) { wallet = await getWalletDetails(existingWallet); await localforage.setItem('wallet', wallet); } else { wallet = existingWallet; } return wallet; }; const migrateLegacyWallet = async (BCH, wallet) => { console.log(`migrateLegacyWallet`); console.log(`legacyWallet`, wallet); const NETWORK = process.env.REACT_APP_NETWORK; const mnemonic = wallet.mnemonic; const rootSeedBuffer = await BCH.Mnemonic.toSeed(mnemonic); let masterHDNode; if (NETWORK === `mainnet`) { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer); } else { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer, 'testnet'); } + + const Path245 = await deriveAccount(BCH, { + masterHDNode, + path: "m/44'/245'/0'/0/0", + }); + const Path145 = await deriveAccount(BCH, { + masterHDNode, + path: "m/44'/145'/0'/0/0", + }); const Path1899 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/1899'/0'/0/0", }); + wallet.Path245 = Path245; + wallet.Path145 = Path145; wallet.Path1899 = Path1899; try { await localforage.setItem('wallet', wallet); } catch (err) { console.log( `Error setting wallet to wallet indexedDb in migrateLegacyWallet()`, ); console.log(err); } return wallet; }; const writeWalletState = async (wallet, newState) => { // Add new state as an object on the active wallet wallet.state = newState; try { await localforage.setItem('wallet', wallet); } catch (err) { console.log(`Error in writeWalletState()`); console.log(err); } }; const getWalletDetails = async wallet => { if (!wallet) { return false; } // Since this info is in localforage now, only get the var const NETWORK = process.env.REACT_APP_NETWORK; const mnemonic = wallet.mnemonic; const rootSeedBuffer = await BCH.Mnemonic.toSeed(mnemonic); let masterHDNode; if (NETWORK === `mainnet`) { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer); } else { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer, 'testnet'); } const Path245 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/245'/0'/0/0", }); const Path145 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/145'/0'/0/0", }); const Path1899 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/1899'/0'/0/0", }); let name = Path1899.cashAddress.slice(12, 17); // Only set the name if it does not currently exist if (wallet && wallet.name) { name = wallet.name; } return { mnemonic: wallet.mnemonic, name, Path245, Path145, Path1899, }; }; const getSavedWallets = async activeWallet => { let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); if (savedWallets === null) { savedWallets = []; } } catch (err) { console.log(`Error in getSavedWallets`); console.log(err); savedWallets = []; } // Even though the active wallet is still stored in savedWallets, don't return it in this function for (let i = 0; i < savedWallets.length; i += 1) { if ( typeof activeWallet !== 'undefined' && activeWallet.name && savedWallets[i].name === activeWallet.name ) { savedWallets.splice(i, 1); } } return savedWallets; }; const activateWallet = async walletToActivate => { /* If the user is migrating from old version to this version, make sure to save the activeWallet 1 - check savedWallets for the previously active wallet 2 - If not there, add it */ let currentlyActiveWallet; try { currentlyActiveWallet = await localforage.getItem('wallet'); } catch (err) { console.log( `Error in localforage.getItem("wallet") in activateWallet()`, ); return false; } // Get savedwallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in localforage.getItem("savedWallets") in activateWallet()`, ); return false; } /* When a legacy user runs cashtab.com/, their active wallet will be migrated to Path1899 by - the getWallet function + the getWallet function. getWallet function also makes sure that each Path has a public key Wallets in savedWallets are migrated when they are activated, in this function Two cases to handle 1 - currentlyActiveWallet has Path1899, but its stored keyvalue pair in savedWallets does not > Update savedWallets so that Path1899 is added to currentlyActiveWallet 2 - walletToActivate does not have Path1899 > Update walletToActivate with Path1899 before activation + + NOTE: since publicKey property is added later, + wallet without public key in Path1899 is also considered legacy and required migration. */ // Need to handle a similar situation with state // If you find the activeWallet in savedWallets but without state, resave active wallet with state // Note you do not have the Case 2 described above here, as wallet state is added in the update() function of useWallet.js // Also note, since state can be expected to change frequently (unlike path deriv), you will likely save it every time you activate a new wallet // Check savedWallets for currentlyActiveWallet let walletInSavedWallets = false; for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === currentlyActiveWallet.name) { walletInSavedWallets = true; // Check savedWallets for unmigrated currentlyActiveWallet - if (!savedWallets[i].Path1899) { + if (isLegacyMigrationRequired(savedWallets[i])) { // Case 1, described above savedWallets[i].Path1899 = currentlyActiveWallet.Path1899; + savedWallets[i].Path145 = currentlyActiveWallet.Path145; + savedWallets[i].Path245 = currentlyActiveWallet.Path245; } /* Update wallet state Note, this makes previous `walletUnmigrated` variable redundant savedWallets[i] should always be updated, since wallet state can be expected to change most of the time */ savedWallets[i].state = currentlyActiveWallet.state; } } // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets") in activateWallet() for unmigrated wallet`, ); } if (!walletInSavedWallets) { console.log(`Wallet is not in saved Wallets, adding`); savedWallets.push(currentlyActiveWallet); // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets") in activateWallet()`, ); } } // If wallet does not have Path1899, add it - - if (!walletToActivate.Path1899) { + // or each of the Path1899, Path145, Path245 does not have a public key, add them + // by calling migrateLagacyWallet() + if (isLegacyMigrationRequired(walletToActivate)) { // Case 2, described above - console.log(`Case 2: Wallet to activate does not have Path1899`); console.log( - `Wallet to activate from SavedWallets does not have Path1899`, + `Case 2: Wallet to activate does not have Path1899 or does not have public key in each Path`, + ); + console.log( + `Wallet to activate from SavedWallets does not have Path1899 or does not have public key in each Path`, ); console.log(`walletToActivate`, walletToActivate); walletToActivate = await migrateLegacyWallet(BCH, walletToActivate); } else { // Otherwise activate it as normal // Now that we have verified the last wallet was saved, we can activate the new wallet try { await localforage.setItem('wallet', walletToActivate); } catch (err) { console.log( `Error in localforage.setItem("wallet", walletToActivate) in activateWallet()`, ); return false; } } // Make sure stored wallet is in correct format to be used as live wallet if (isValidStoredWallet(walletToActivate)) { // Convert all the token balance figures to big numbers const liveWalletState = loadStoredWallet(walletToActivate.state); walletToActivate.state = liveWalletState; } return walletToActivate; }; const renameWallet = async (oldName, newName) => { // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in renameWallet`, ); console.log(err); return false; } // Verify that no existing wallet has this name for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === newName) { // return an error return false; } } // change name of desired wallet for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === oldName) { // Replace the name of this entry with the new name savedWallets[i].name = newName; } } // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", savedWallets) in renameWallet()`, ); return false; } return true; }; const deleteWallet = async walletToBeDeleted => { // delete a wallet // returns true if wallet is successfully deleted // otherwise returns false // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in deleteWallet`, ); console.log(err); return false; } // Iterate over to find the wallet to be deleted // Verify that no existing wallet has this name let walletFoundAndRemoved = false; for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === walletToBeDeleted.name) { // Verify it has the same mnemonic too, that's a better UUID if (savedWallets[i].mnemonic === walletToBeDeleted.mnemonic) { // Delete it savedWallets.splice(i, 1); walletFoundAndRemoved = true; } } } // If you don't find the wallet, return false if (!walletFoundAndRemoved) { return false; } // Resave savedWallets less the deleted wallet try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", savedWallets) in deleteWallet()`, ); return false; } return true; }; const addNewSavedWallet = async importMnemonic => { // Add a new wallet to savedWallets from importMnemonic or just new wallet const lang = 'english'; // create 128 bit BIP39 mnemonic const Bip39128BitMnemonic = importMnemonic ? importMnemonic : BCH.Mnemonic.generate(128, BCH.Mnemonic.wordLists()[lang]); const newSavedWallet = await getWalletDetails({ mnemonic: Bip39128BitMnemonic.toString(), }); // Get saved wallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); // If this doesn't exist yet, savedWallets === null if (savedWallets === null) { savedWallets = []; } } catch (err) { console.log( `Error in savedWallets = await localforage.getItem("savedWallets") in addNewSavedWallet()`, ); console.log(err); console.log(`savedWallets in error state`, savedWallets); } // If this wallet is from an imported mnemonic, make sure it does not already exist in savedWallets if (importMnemonic) { for (let i = 0; i < savedWallets.length; i += 1) { // Check for condition "importing new wallet that is already in savedWallets" if (savedWallets[i].mnemonic === importMnemonic) { // set this as the active wallet to keep name history console.log( `Error: this wallet already exists in savedWallets`, ); console.log(`Wallet not being added.`); return false; } } } // add newSavedWallet savedWallets.push(newSavedWallet); // update savedWallets try { await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", activeWallet) called in createWallet with ${importMnemonic}`, ); console.log(`savedWallets`, savedWallets); console.log(err); } return true; }; const createWallet = async importMnemonic => { const lang = 'english'; // create 128 bit BIP39 mnemonic const Bip39128BitMnemonic = importMnemonic ? importMnemonic : BCH.Mnemonic.generate(128, BCH.Mnemonic.wordLists()[lang]); const wallet = await getWalletDetails({ mnemonic: Bip39128BitMnemonic.toString(), }); try { await localforage.setItem('wallet', wallet); } catch (err) { console.log( `Error setting wallet to wallet indexedDb in createWallet()`, ); console.log(err); } // Since this function is only called from OnBoarding.js, also add this to the saved wallet try { await localforage.setItem('savedWallets', [wallet]); } catch (err) { console.log( `Error setting wallet to savedWallets indexedDb in createWallet()`, ); console.log(err); } return wallet; }; const validateMnemonic = ( mnemonic, wordlist = BCH.Mnemonic.wordLists().english, ) => { let mnemonicTestOutput; try { mnemonicTestOutput = BCH.Mnemonic.validate(mnemonic, wordlist); if (mnemonicTestOutput === 'Valid mnemonic') { return true; } else { return false; } } catch (err) { console.log(err); return false; } }; const handleUpdateWallet = async setWallet => { await loadWalletFromStorageOnStartup(setWallet); }; const loadCashtabSettings = async () => { // get settings object from localforage let localSettings; try { localSettings = await localforage.getItem('settings'); // If there is no keyvalue pair in localforage with key 'settings' if (localSettings === null) { // Create one with the default settings from Ticker.js localforage.setItem('settings', currency.defaultSettings); // Set state to default settings setCashtabSettings(currency.defaultSettings); return currency.defaultSettings; } } catch (err) { console.log(`Error getting cashtabSettings`, err); // TODO If they do not exist, write them // TODO add function to change them setCashtabSettings(currency.defaultSettings); return currency.defaultSettings; } // If you found an object in localforage at the settings key, make sure it's valid if (isValidCashtabSettings(localSettings)) { setCashtabSettings(localSettings); return localSettings; } // if not valid, also set cashtabSettings to default setCashtabSettings(currency.defaultSettings); return currency.defaultSettings; }; // With different currency selections possible, need unique intervals for price checks // Must be able to end them and set new ones with new currencies const initializeFiatPriceApi = async selectedFiatCurrency => { // Update fiat price and confirm it is set to make sure ap keeps loading state until this is updated await fetchBchPrice(selectedFiatCurrency); // Set interval for updating the price with given currency const thisFiatInterval = setInterval(function () { fetchBchPrice(selectedFiatCurrency); }, 60000); // set interval in state setCheckFiatInterval(thisFiatInterval); }; const clearFiatPriceApi = fiatPriceApi => { // Clear fiat price check interval of previously selected currency clearInterval(fiatPriceApi); }; const changeCashtabSettings = async (key, newValue) => { // Set loading to true as you do not want to display the fiat price of the last currency // loading = true will lock the UI until the fiat price has updated setLoading(true); // Get settings from localforage let currentSettings; let newSettings; try { currentSettings = await localforage.getItem('settings'); } catch (err) { console.log(`Error in changeCashtabSettings`, err); // Set fiat price to null, which disables fiat sends throughout the app setFiatPrice(null); // Unlock the UI setLoading(false); return; } // Make sure function was called with valid params if ( Object.keys(currentSettings).includes(key) && currency.settingsValidation[key].includes(newValue) ) { // Update settings newSettings = currentSettings; newSettings[key] = newValue; } // Set new settings in state so they are available in context throughout the app setCashtabSettings(newSettings); // If this settings change adjusted the fiat currency, update fiat price if (key === 'fiatCurrency') { clearFiatPriceApi(checkFiatInterval); initializeFiatPriceApi(newValue); } // Write new settings in localforage try { await localforage.setItem('settings', newSettings); } catch (err) { console.log( `Error writing newSettings object to localforage in changeCashtabSettings`, err, ); console.log(`newSettings`, newSettings); // do nothing. If this happens, the user will see default currency next time they load the app. } setLoading(false); }; // Parse for incoming XEC transactions if ( previousBalances && balances && 'totalBalance' in previousBalances && 'totalBalance' in balances && new BigNumber(balances.totalBalance) .minus(previousBalances.totalBalance) .gt(0) ) { xecReceivedNotification( balances, previousBalances, cashtabSettings, fiatPrice, ); } // Parse for incoming eToken transactions if ( tokens && tokens[0] && tokens[0].balance && previousTokens && previousTokens[0] && previousTokens[0].balance ) { // If tokens length is greater than previousTokens length, a new token has been received // Note, a user could receive a new token, AND more of existing tokens in between app updates // In this case, the app will only notify about the new token // TODO better handling for all possible cases to cover this // TODO handle with websockets for better response time, less complicated calc if (tokens.length > previousTokens.length) { // Find the new token const tokenIds = tokens.map(({ tokenId }) => tokenId); const previousTokenIds = previousTokens.map( ({ tokenId }) => tokenId, ); //console.log(`tokenIds`, tokenIds); //console.log(`previousTokenIds`, previousTokenIds); // An array with the new token Id const newTokenIdArr = tokenIds.filter( tokenId => !previousTokenIds.includes(tokenId), ); // It's possible that 2 new tokens were received // To do, handle this case const newTokenId = newTokenIdArr[0]; //console.log(newTokenId); // How much of this tokenId did you get? // would be at // Find where the newTokenId is const receivedTokenObjectIndex = tokens.findIndex( x => x.tokenId === newTokenId, ); //console.log(`receivedTokenObjectIndex`, receivedTokenObjectIndex); // Calculate amount received //console.log(`receivedTokenObject:`, tokens[receivedTokenObjectIndex]); const receivedSlpQty = tokens[receivedTokenObjectIndex].balance.toString(); const receivedSlpTicker = tokens[receivedTokenObjectIndex].info.tokenTicker; const receivedSlpName = tokens[receivedTokenObjectIndex].info.tokenName; //console.log(`receivedSlpQty`, receivedSlpQty); // Notification if you received SLP if (receivedSlpQty > 0) { eTokenReceivedNotification( currency, receivedSlpTicker, receivedSlpQty, receivedSlpName, ); } // } else { // If tokens[i].balance > previousTokens[i].balance, a new SLP tx of an existing token has been received // Note that tokens[i].balance is of type BigNumber for (let i = 0; i < tokens.length; i += 1) { if (tokens[i].balance.gt(previousTokens[i].balance)) { // Received this token // console.log(`previousTokenId`, previousTokens[i].tokenId); // console.log(`currentTokenId`, tokens[i].tokenId); if (previousTokens[i].tokenId !== tokens[i].tokenId) { console.log( `TokenIds do not match, breaking from SLP notifications`, ); // Then don't send the notification // Also don't 'continue' ; this means you have sent a token, just stop iterating through break; } const receivedSlpQty = tokens[i].balance.minus( previousTokens[i].balance, ); const receivedSlpTicker = tokens[i].info.tokenTicker; const receivedSlpName = tokens[i].info.tokenName; eTokenReceivedNotification( currency, receivedSlpTicker, receivedSlpQty, receivedSlpName, ); } } } } // Update wallet every 10s useAsyncTimeout(async () => { const wallet = await getWallet(); update({ wallet, }).finally(() => { setLoading(false); }); }, 10000); const fetchBchPrice = async ( fiatCode = cashtabSettings ? cashtabSettings.fiatCurrency : 'usd', ) => { // Split this variable out in case coingecko changes const cryptoId = currency.coingeckoId; // Keep this in the code, because different URLs will have different outputs require different parsing const priceApiUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${cryptoId}&vs_currencies=${fiatCode}&include_last_updated_at=true`; let bchPrice; let bchPriceJson; try { bchPrice = await fetch(priceApiUrl); //console.log(`bchPrice`, bchPrice); } catch (err) { console.log(`Error fetching BCH Price`); console.log(err); } try { bchPriceJson = await bchPrice.json(); //console.log(`bchPriceJson`, bchPriceJson); let bchPriceInFiat = bchPriceJson[cryptoId][fiatCode]; const validEcashPrice = typeof bchPriceInFiat === 'number'; if (validEcashPrice) { setFiatPrice(bchPriceInFiat); } else { // If API price looks fishy, do not allow app to send using fiat settings setFiatPrice(null); } } catch (err) { console.log(`Error parsing price API response to JSON`); console.log(err); } }; useEffect(async () => { handleUpdateWallet(setWallet); const initialSettings = await loadCashtabSettings(); initializeFiatPriceApi(initialSettings.fiatCurrency); }, []); return { BCH, wallet, fiatPrice, loading, apiError, cashtabSettings, changeCashtabSettings, getActiveWalletFromLocalForage, getWallet, validateMnemonic, getWalletDetails, getSavedWallets, migrateLegacyWallet, createWallet: async importMnemonic => { setLoading(true); const newWallet = await createWallet(importMnemonic); setWallet(newWallet); update({ wallet: newWallet, }).finally(() => setLoading(false)); }, activateWallet: async walletToActivate => { setLoading(true); const newWallet = await activateWallet(walletToActivate); setWallet(newWallet); if (isValidStoredWallet(walletToActivate)) { // If you have all state parameters needed in storage, immediately load the wallet setLoading(false); } else { // If the wallet is missing state parameters in storage, wait for API info // This handles case of unmigrated legacy wallet update({ wallet: newWallet, }).finally(() => setLoading(false)); } }, addNewSavedWallet, renameWallet, deleteWallet, }; }; export default useWallet; diff --git a/web/cashtab/src/utils/__mocks__/mockLegacyWallets.js b/web/cashtab/src/utils/__mocks__/mockLegacyWallets.js new file mode 100644 index 000000000..e0507e91f --- /dev/null +++ b/web/cashtab/src/utils/__mocks__/mockLegacyWallets.js @@ -0,0 +1,164 @@ +export const missingPath1899Wallet = { + Path145: { + cashAddress: 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z', + fundingAddress: + 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt', + legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF', + slpAddress: 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + }, + Path245: { + cashAddress: 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298', + fundingAddress: + 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG', + legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP', + slpAddress: 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + }, + mnemonic: + 'apart vacuum color cream drama kind foil history hurt alone ask census', + name: 'Missing Path1899', + state: {}, +}; + +export const missingPublicKeyInPath1899Wallet = { + Path145: { + cashAddress: 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z', + fundingAddress: + 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt', + legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF', + publicKey: + '02fe5308d77bcce825068a9e46adc6f032dbbe39167a7b6d05ac563ac71d8b186e', + slpAddress: 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + }, + Path1899: { + cashAddress: 'bitcoincash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cptzgcqy6', + fundingAddress: + 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', + fundingWif: 'Kx4FiBMvKK1iXjFk5QTaAK6E4mDGPjmwDZ2HDKGUZpE4gCXMaPe9', + legacyAddress: '1J1Aq5tAAYxZgSDRo8soKM2Rb41z3xrYpm', + slpAddress: 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', + }, + Path245: { + cashAddress: 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298', + fundingAddress: + 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG', + legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP', + publicKey: + '03c4a69fd90c8b196683216cffd2943a7b13b0db0812e44a4ff156ac7e03fc4ed7', + slpAddress: 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + }, + mnemonic: + 'apart vacuum color cream drama kind foil history hurt alone ask census', + name: 'Missing Public Key in Path1899', + state: {}, +}; + +export const missingPublicKeyInPath145Wallet = { + Path145: { + cashAddress: 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z', + fundingAddress: + 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt', + legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF', + slpAddress: 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + }, + Path1899: { + cashAddress: 'bitcoincash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cptzgcqy6', + fundingAddress: + 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', + fundingWif: 'Kx4FiBMvKK1iXjFk5QTaAK6E4mDGPjmwDZ2HDKGUZpE4gCXMaPe9', + legacyAddress: '1J1Aq5tAAYxZgSDRo8soKM2Rb41z3xrYpm', + publicKey: + '02a06bb380cf180d703f6f80796a13555aefff817d1f6f842f1e5c555b15f0fa70', + slpAddress: 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', + }, + Path245: { + cashAddress: 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298', + fundingAddress: + 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG', + legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP', + publicKey: + '03c4a69fd90c8b196683216cffd2943a7b13b0db0812e44a4ff156ac7e03fc4ed7', + slpAddress: 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + }, + mnemonic: + 'apart vacuum color cream drama kind foil history hurt alone ask census', + name: 'Missing Public Key in Path145', + state: {}, +}; + +export const missingPublicKeyInPath245Wallet = { + Path145: { + cashAddress: 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z', + fundingAddress: + 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt', + legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF', + publicKey: + '02fe5308d77bcce825068a9e46adc6f032dbbe39167a7b6d05ac563ac71d8b186e', + slpAddress: 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + }, + Path1899: { + cashAddress: 'bitcoincash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cptzgcqy6', + fundingAddress: + 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', + fundingWif: 'Kx4FiBMvKK1iXjFk5QTaAK6E4mDGPjmwDZ2HDKGUZpE4gCXMaPe9', + legacyAddress: '1J1Aq5tAAYxZgSDRo8soKM2Rb41z3xrYpm', + publicKey: + '02a06bb380cf180d703f6f80796a13555aefff817d1f6f842f1e5c555b15f0fa70', + slpAddress: 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', + }, + Path245: { + cashAddress: 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298', + fundingAddress: + 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG', + legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP', + slpAddress: 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + }, + mnemonic: + 'apart vacuum color cream drama kind foil history hurt alone ask census', + name: 'Missing Public Key in Path245', + state: {}, +}; + +export const notLegacyWallet = { + Path145: { + cashAddress: 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z', + fundingAddress: + 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt', + legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF', + publicKey: + '02fe5308d77bcce825068a9e46adc6f032dbbe39167a7b6d05ac563ac71d8b186e', + slpAddress: 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu', + }, + Path1899: { + cashAddress: 'bitcoincash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cptzgcqy6', + fundingAddress: + 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', + fundingWif: 'Kx4FiBMvKK1iXjFk5QTaAK6E4mDGPjmwDZ2HDKGUZpE4gCXMaPe9', + legacyAddress: '1J1Aq5tAAYxZgSDRo8soKM2Rb41z3xrYpm', + publicKey: + '02a06bb380cf180d703f6f80796a13555aefff817d1f6f842f1e5c555b15f0fa70', + slpAddress: 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y', + }, + Path245: { + cashAddress: 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298', + fundingAddress: + 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG', + legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP', + publicKey: + '03c4a69fd90c8b196683216cffd2943a7b13b0db0812e44a4ff156ac7e03fc4ed7', + slpAddress: 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me', + }, + mnemonic: + 'apart vacuum color cream drama kind foil history hurt alone ask census', + name: 'Current Wallet, Not Legacy', + state: {}, +}; diff --git a/web/cashtab/src/utils/__tests__/cashMethods.test.js b/web/cashtab/src/utils/__tests__/cashMethods.test.js index 84d829d83..cae0946b7 100644 --- a/web/cashtab/src/utils/__tests__/cashMethods.test.js +++ b/web/cashtab/src/utils/__tests__/cashMethods.test.js @@ -1,171 +1,201 @@ import { fromSmallestDenomination, formatBalance, batchArray, flattenBatchedHydratedUtxos, loadStoredWallet, isValidStoredWallet, fromLegacyDecimals, convertToEcashPrefix, convertEtokenToSimpleledger, checkNullUtxosForTokenStatus, confirmNonEtokenUtxos, + isLegacyMigrationRequired, } from '@utils/cashMethods'; import { unbatchedArray, arrayBatchedByThree, } from '../__mocks__/mockBatchedArrays'; import { unflattenedHydrateUtxosResponse, flattenedHydrateUtxosResponse, } from '../__mocks__/flattenBatchedHydratedUtxosMocks'; import { cachedUtxos, utxosLoadedFromCache, } from '../__mocks__/mockCachedUtxos'; import { validStoredWallet, invalidStoredWallet, } from '../__mocks__/mockStoredWallets'; import { mockTxDataResults, mockNonEtokenUtxos, mockTxDataResultsWithEtoken, mockHydratedUtxosWithNullValues, mockHydratedUtxosWithNullValuesSetToFalse, } from '../__mocks__/nullUtxoMocks'; +import { + missingPath1899Wallet, + missingPublicKeyInPath1899Wallet, + missingPublicKeyInPath145Wallet, + missingPublicKeyInPath245Wallet, + notLegacyWallet, +} from '../__mocks__/mockLegacyWallets'; + describe('Correctly executes cash utility functions', () => { it(`Correctly converts smallest base unit to smallest decimal for cashDecimals = 2`, () => { expect(fromSmallestDenomination(1, 2)).toBe(0.01); }); it(`Correctly converts largest base unit to smallest decimal for cashDecimals = 2`, () => { expect(fromSmallestDenomination(1000000012345678, 2)).toBe( 10000000123456.78, ); }); it(`Correctly converts smallest base unit to smallest decimal for cashDecimals = 8`, () => { expect(fromSmallestDenomination(1, 8)).toBe(0.00000001); }); it(`Correctly converts largest base unit to smallest decimal for cashDecimals = 8`, () => { expect(fromSmallestDenomination(1000000012345678, 8)).toBe( 10000000.12345678, ); }); it(`Correctly converts an array of length 10 to an array of 4 arrays, each with max length 3`, () => { expect(batchArray(unbatchedArray, 3)).toStrictEqual( arrayBatchedByThree, ); }); it(`If array length is less than batch size, return original array as first and only element of new array`, () => { expect(batchArray(unbatchedArray, 20)).toStrictEqual([unbatchedArray]); }); it(`Flattens hydrateUtxos from Promise.all() response into array that can be parsed by getSlpBalancesAndUtxos`, () => { expect( flattenBatchedHydratedUtxos(unflattenedHydrateUtxosResponse), ).toStrictEqual(flattenedHydrateUtxosResponse); }); it(`Accepts a cachedWalletState that has not preserved BigNumber object types, and returns the same wallet state with BigNumber type re-instituted`, () => { expect(loadStoredWallet(cachedUtxos)).toStrictEqual( utxosLoadedFromCache, ); }); it(`Recognizes a stored wallet as valid if it has all required fields`, () => { expect(isValidStoredWallet(validStoredWallet)).toBe(true); }); it(`Recognizes a stored wallet as invalid if it is missing required fields`, () => { expect(isValidStoredWallet(invalidStoredWallet)).toBe(false); }); it(`Converts a legacy BCH amount to an XEC amount`, () => { expect(fromLegacyDecimals(0.00000546, 2)).toStrictEqual(5.46); }); it(`Leaves a legacy BCH amount unchanged if currency.cashDecimals is 8`, () => { expect(fromLegacyDecimals(0.00000546, 8)).toStrictEqual(0.00000546); }); it(`convertToEcashPrefix converts a bitcoincash: prefixed address to an ecash: prefixed address`, () => { expect( convertToEcashPrefix( 'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr', ), ).toBe('ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'); }); it(`convertToEcashPrefix returns an ecash: prefix address unchanged`, () => { expect( convertToEcashPrefix( 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035', ), ).toBe('ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'); }); it(`convertEtokenToSimpleledger returns an etoken: prefix address as simpleledger:`, () => { expect( convertEtokenToSimpleledger( 'etoken:qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r', ), ).toBe('simpleledger:qz2708636snqhsxu8wnlka78h6fdp77ar5syue64fa'); }); it(`convertEtokenToSimpleledger returns a simpleledger: prefix address unchanged`, () => { expect( convertEtokenToSimpleledger( 'simpleledger:qz2708636snqhsxu8wnlka78h6fdp77ar5syue64fa', ), ).toBe('simpleledger:qz2708636snqhsxu8wnlka78h6fdp77ar5syue64fa'); }); it(`test formatBalance with an input of 0`, () => { expect(formatBalance('0')).toBe('0'); }); it(`test formatBalance with zero XEC balance input`, () => { expect(formatBalance('0', 'en-US')).toBe('0'); }); it(`test formatBalance with a small XEC balance input with 2+ decimal figures`, () => { expect(formatBalance('1574.5445', 'en-US')).toBe('1,574.54'); }); it(`test formatBalance with 1 Million XEC balance input`, () => { expect(formatBalance('1000000', 'en-US')).toBe('1,000,000'); }); it(`test formatBalance with 1 Billion XEC balance input`, () => { expect(formatBalance('1000000000', 'en-US')).toBe('1,000,000,000'); }); it(`test formatBalance with total supply as XEC balance input`, () => { expect(formatBalance('21000000000000', 'en-US')).toBe( '21,000,000,000,000', ); }); it(`test formatBalance with > total supply as XEC balance input`, () => { expect(formatBalance('31000000000000', 'en-US')).toBe( '31,000,000,000,000', ); }); it(`test formatBalance with no balance`, () => { expect(formatBalance('', 'en-US')).toBe('0'); }); it(`test formatBalance with null input`, () => { expect(formatBalance(null, 'en-US')).toBe('0'); }); it(`test formatBalance with undefined as input`, () => { expect(formatBalance(undefined, 'en-US')).toBe('NaN'); }); it(`test formatBalance with non-numeric input`, () => { expect(formatBalance('CainBCHA', 'en-US')).toBe('NaN'); }); it(`Correctly parses utxo vout tx data to confirm the transactions are not eToken txs`, () => { expect(checkNullUtxosForTokenStatus(mockTxDataResults)).toStrictEqual( mockNonEtokenUtxos, ); }); it(`Correctly parses utxo vout tx data and screens out an eToken by asm field`, () => { expect( checkNullUtxosForTokenStatus(mockTxDataResultsWithEtoken), ).toStrictEqual([]); }); it(`Changes isValid from 'null' to 'false' for confirmed nonEtokenUtxos`, () => { expect( confirmNonEtokenUtxos( mockHydratedUtxosWithNullValues, mockNonEtokenUtxos, ), ).toStrictEqual(mockHydratedUtxosWithNullValuesSetToFalse); }); + it(`Recognizes a wallet with missing Path1889 is a Legacy Wallet and requires migration`, () => { + expect(isLegacyMigrationRequired(missingPath1899Wallet)).toBe(true); + }); + it(`Recognizes a wallet with missing PublicKey in Path1889 is a Legacy Wallet and requires migration`, () => { + expect( + isLegacyMigrationRequired(missingPublicKeyInPath1899Wallet), + ).toBe(true); + }); + it(`Recognizes a wallet with missing PublicKey in Path145 is a Legacy Wallet and requires migration`, () => { + expect(isLegacyMigrationRequired(missingPublicKeyInPath145Wallet)).toBe( + true, + ); + }); + it(`Recognizes a wallet with missing PublicKey in Path245 is a Legacy Wallet and requires migration`, () => { + expect(isLegacyMigrationRequired(missingPublicKeyInPath245Wallet)).toBe( + true, + ); + }); + it(`Recognizes a latest, current wallet that does not require migration`, () => { + expect(isLegacyMigrationRequired(notLegacyWallet)).toBe(false); + }); }); diff --git a/web/cashtab/src/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js index 968635d8f..87c77d074 100644 --- a/web/cashtab/src/utils/cashMethods.js +++ b/web/cashtab/src/utils/cashMethods.js @@ -1,277 +1,293 @@ import { currency } from '@components/Common/Ticker'; import BigNumber from 'bignumber.js'; import cashaddr from 'ecashaddrjs'; export const fromLegacyDecimals = ( amount, cashDecimals = currency.cashDecimals, ) => { // Input 0.00000546 BCH // Output 5.46 XEC or 0.00000546 BCH, depending on currency.cashDecimals const amountBig = new BigNumber(amount); const conversionFactor = new BigNumber(10 ** (8 - cashDecimals)); const amountSmallestDenomination = amountBig .times(conversionFactor) .toNumber(); return amountSmallestDenomination; }; export const fromSmallestDenomination = ( amount, cashDecimals = currency.cashDecimals, ) => { const amountBig = new BigNumber(amount); const multiplier = new BigNumber(10 ** (-1 * cashDecimals)); const amountInBaseUnits = amountBig.times(multiplier); return amountInBaseUnits.toNumber(); }; export const toSmallestDenomination = ( sendAmount, cashDecimals = currency.cashDecimals, ) => { // Replace the BCH.toSatoshi method with an equivalent function that works for arbitrary decimal places // Example, for an 8 decimal place currency like Bitcoin // Input: a BigNumber of the amount of Bitcoin to be sent // Output: a BigNumber of the amount of satoshis to be sent, or false if input is invalid // Validate // Input should be a BigNumber with no more decimal places than cashDecimals const isValidSendAmount = BigNumber.isBigNumber(sendAmount) && sendAmount.dp() <= cashDecimals; if (!isValidSendAmount) { return false; } const conversionFactor = new BigNumber(10 ** cashDecimals); const sendAmountSmallestDenomination = sendAmount.times(conversionFactor); return sendAmountSmallestDenomination; }; export const formatBalance = (unformattedBalance, optionalLocale) => { try { if (optionalLocale === undefined) { return new Number(unformattedBalance).toLocaleString({ maximumFractionDigits: currency.cashDecimals, }); } return new Number(unformattedBalance).toLocaleString(optionalLocale, { maximumFractionDigits: currency.cashDecimals, }); } catch (err) { console.log(`Error in formatBalance for ${unformattedBalance}`); console.log(err); return unformattedBalance; } }; export const batchArray = (inputArray, batchSize) => { // take an array of n elements, return an array of arrays each of length batchSize const batchedArray = []; for (let i = 0; i < inputArray.length; i += batchSize) { const tempArray = inputArray.slice(i, i + batchSize); batchedArray.push(tempArray); } return batchedArray; }; export const flattenBatchedHydratedUtxos = batchedHydratedUtxoDetails => { // Return same result as if only the bulk API call were made // to do this, just need to move all utxos under one slpUtxos /* given [ { slpUtxos: [ { utxos: [], address: '', } ], }, { slpUtxos: [ { utxos: [], address: '', } ], } ] return [ { slpUtxos: [ { utxos: [], address: '' }, { utxos: [], address: '' }, ] } */ const flattenedBatchedHydratedUtxos = { slpUtxos: [] }; for (let i = 0; i < batchedHydratedUtxoDetails.length; i += 1) { const theseSlpUtxos = batchedHydratedUtxoDetails[i].slpUtxos[0]; flattenedBatchedHydratedUtxos.slpUtxos.push(theseSlpUtxos); } return flattenedBatchedHydratedUtxos; }; export const loadStoredWallet = walletStateFromStorage => { // Accept cached tokens array that does not save BigNumber type of BigNumbers // Return array with BigNumbers converted // See BigNumber.js api for how to create a BigNumber object from an object // https://mikemcl.github.io/bignumber.js/ const liveWalletState = walletStateFromStorage; const { slpBalancesAndUtxos, tokens } = liveWalletState; for (let i = 0; i < tokens.length; i += 1) { const thisTokenBalance = tokens[i].balance; thisTokenBalance._isBigNumber = true; tokens[i].balance = new BigNumber(thisTokenBalance); } // Also confirm balance is correct // Necessary step in case currency.decimals changed since last startup const balancesRebased = normalizeBalance(slpBalancesAndUtxos); liveWalletState.balances = balancesRebased; return liveWalletState; }; export const normalizeBalance = slpBalancesAndUtxos => { const totalBalanceInSatoshis = slpBalancesAndUtxos.nonSlpUtxos.reduce( (previousBalance, utxo) => previousBalance + utxo.value, 0, ); return { totalBalanceInSatoshis, totalBalance: fromSmallestDenomination(totalBalanceInSatoshis), }; }; export const isValidStoredWallet = walletStateFromStorage => { return ( typeof walletStateFromStorage === 'object' && 'state' in walletStateFromStorage && typeof walletStateFromStorage.state === 'object' && 'balances' in walletStateFromStorage.state && 'utxos' in walletStateFromStorage.state && 'hydratedUtxoDetails' in walletStateFromStorage.state && 'slpBalancesAndUtxos' in walletStateFromStorage.state && 'tokens' in walletStateFromStorage.state ); }; export const getWalletState = wallet => { if (!wallet || !wallet.state) { return { balances: { totalBalance: 0, totalBalanceInSatoshis: 0 }, hydratedUtxoDetails: {}, tokens: [], slpBalancesAndUtxos: {}, parsedTxHistory: [], utxos: [], }; } return wallet.state; }; export function convertToEcashPrefix(bitcoincashPrefixedAddress) { // Prefix-less addresses may be valid, but the cashaddr.decode function used below // will throw an error without a prefix. Hence, must ensure prefix to use that function. const hasPrefix = bitcoincashPrefixedAddress.includes(':'); if (hasPrefix) { // Is it bitcoincash: or simpleledger: const { type, hash, prefix } = cashaddr.decode( bitcoincashPrefixedAddress, ); let newPrefix; if (prefix === 'bitcoincash') { newPrefix = 'ecash'; } else if (prefix === 'simpleledger') { newPrefix = 'etoken'; } else { return bitcoincashPrefixedAddress; } const convertedAddress = cashaddr.encode(newPrefix, type, hash); return convertedAddress; } else { return bitcoincashPrefixedAddress; } } export function convertEtokenToSimpleledger(etokenPrefixedAddress) { // Prefix-less addresses may be valid, but the cashaddr.decode function used below // will throw an error without a prefix. Hence, must ensure prefix to use that function. const hasPrefix = etokenPrefixedAddress.includes(':'); if (hasPrefix) { // Is it bitcoincash: or simpleledger: const { type, hash, prefix } = cashaddr.decode(etokenPrefixedAddress); let newPrefix; if (prefix === 'etoken') { newPrefix = 'simpleledger'; } else { // return address with no change return etokenPrefixedAddress; } const convertedAddress = cashaddr.encode(newPrefix, type, hash); return convertedAddress; } else { // return address with no change return etokenPrefixedAddress; } } export const confirmNonEtokenUtxos = (hydratedUtxos, nonEtokenUtxos) => { // scan through hydratedUtxoDetails for (let i = 0; i < hydratedUtxos.length; i += 1) { // Find utxos with txids matching nonEtokenUtxos if (nonEtokenUtxos.includes(hydratedUtxos[i].txid)) { // Confirm that such utxos are not eToken utxos hydratedUtxos[i].isValid = false; } } return hydratedUtxos; }; export const checkNullUtxosForTokenStatus = txDataResults => { const nonEtokenUtxos = []; for (let j = 0; j < txDataResults.length; j += 1) { const thisUtxoTxid = txDataResults[j].txid; const thisUtxoVout = txDataResults[j].details.vout; // Iterate over outputs for (let k = 0; k < thisUtxoVout.length; k += 1) { const thisOutput = thisUtxoVout[k]; if (thisOutput.scriptPubKey.type === 'nulldata') { const asmOutput = thisOutput.scriptPubKey.asm; if (asmOutput.includes('OP_RETURN 5262419')) { // then it's an eToken tx that has not been properly validated // Do not include it in nonEtokenUtxos // App will ignore it until SLPDB is able to validate it console.log( `utxo ${thisUtxoTxid} requires further eToken validation, ignoring`, ); } else { // Otherwise it's just an OP_RETURN tx that SLPDB has some issue with // It should still be in the user's utxo set // Include it in nonEtokenUtxos console.log( `utxo ${thisUtxoTxid} is not an eToken tx, adding to nonSlpUtxos`, ); nonEtokenUtxos.push(thisUtxoTxid); } } } } return nonEtokenUtxos; }; + +export const isLegacyMigrationRequired = wallet => { + // If the wallet does not have Path1899, + // Or each Path1899, Path145, Path245 does not have a public key + // Then it requires migration + if ( + !wallet.Path1899 || + !wallet.Path1899.publicKey || + !wallet.Path145.publicKey || + !wallet.Path245.publicKey + ) { + return true; + } + + return false; +};