diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js index 266d1d302..2379f32ca 100644 --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -1,1511 +1,1492 @@ import { useState, useEffect } from 'react'; import usePrevious from 'hooks/usePrevious'; import useInterval from './useInterval'; import useBCH from 'hooks/useBCH'; import BigNumber from 'bignumber.js'; import Bitcoin from '@psf/bitcoincashjs-lib'; import coininfo from 'utils/coininfo'; import { loadStoredWallet, isValidStoredWallet, isLegacyMigrationRequired, getHashArrayFromWallet, isActiveWebsocket, getWalletBalanceFromUtxos, toHash160, } from 'utils/cashMethods'; import { isValidCashtabSettings, isValidCashtabCache, isValidContactList, parseInvalidSettingsForMigration, - validateMnemonicWordList, } from 'utils/validation'; import localforage from 'localforage'; import { currency } from 'components/Common/Ticker'; import { xecReceivedNotification, xecReceivedNotificationWebsocket, eTokenReceivedNotification, } from 'components/Common/Notifications'; import { getUtxosChronik, organizeUtxosByType, getPreliminaryTokensArray, finalizeTokensArray, finalizeSlpUtxos, getTxHistoryChronik, parseChronikTx, } from 'utils/chronik'; import { ChronikClient } from 'chronik-client'; // For XEC, eCash chain: const chronik = new ChronikClient(currency.chronikUrl); import cashaddr from 'ecashaddrjs'; import * as bip39 from 'bip39'; import * as randomBytes from 'randombytes'; const useWallet = () => { const [walletRefreshInterval, setWalletRefreshInterval] = useState( currency.websocketDisconnectedRefreshInterval, ); const [wallet, setWallet] = useState(false); const [chronikWebsocket, setChronikWebsocket] = useState(null); const [contactList, setContactList] = useState([{}]); const [cashtabSettings, setCashtabSettings] = useState(false); const [cashtabCache, setCashtabCache] = useState( currency.defaultCashtabCache, ); const [fiatPrice, setFiatPrice] = useState(null); const [apiError, setApiError] = useState(false); const [checkFiatInterval, setCheckFiatInterval] = useState(null); const [hasUpdated, setHasUpdated] = useState(false); const { getBCH } = useBCH(); const [loading, setLoading] = useState(true); const [apiIndex, setApiIndex] = useState(0); const [BCH, setBCH] = useState(getBCH(apiIndex)); const { balances, tokens } = isValidStoredWallet(wallet) ? wallet.state : { balances: {}, tokens: [], utxos: null, }; const previousBalances = usePrevious(balances); const previousTokens = usePrevious(tokens); // 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 deriveAccount = async (BCH, { masterHDNode, path }) => { const node = masterHDNode.derivePath(path); const publicKey = node.getPublicKeyBuffer().toString('hex'); const cashAddress = cashaddr.encode( 'bitcoincash', 'P2PKH', node.getIdentifier(), ); const hash160 = toHash160(cashAddress); const slpAddress = BCH.SLP.Address.toSLPAddress(cashAddress); return { publicKey, hash160, cashAddress, slpAddress, fundingWif: node.keyPair.toWIF(), 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); } console.log(`Active wallet is not valid, loading params from API`); // Loading will remain true until API calls populate this legacy wallet setWallet(wallet); }; const update = async ({ wallet }) => { //console.log(`tick()`); //console.time("update"); // Check if walletRefreshInterval is set to 10, i.e. this was called by websocket tx detection // If walletRefreshInterval is 10, set it back to the usual refresh rate if (walletRefreshInterval === 10) { setWalletRefreshInterval( currency.websocketConnectedRefreshInterval, ); } try { if (!wallet) { return; } /* This strange data structure is necessary because chronik requires the hash160 of an address to tell you what utxos are at that address */ const hash160AndAddressObjArray = [ { address: wallet.Path145.cashAddress, hash160: wallet.Path145.hash160, }, { address: wallet.Path245.cashAddress, hash160: wallet.Path245.hash160, }, { address: wallet.Path1899.cashAddress, hash160: wallet.Path1899.hash160, }, ]; // Check that server is live try { await BCH.Blockchain.getBlockCount(); } catch (err) { console.log( `Error in BCH.Blockchain.getBlockCount, the full node is likely down`, err, ); throw new Error(`Node unavailable`); } const chronikUtxos = await getUtxosChronik( chronik, hash160AndAddressObjArray, ); const { preliminarySlpUtxos, nonSlpUtxos } = organizeUtxosByType(chronikUtxos); const preliminaryTokensArray = getPreliminaryTokensArray(preliminarySlpUtxos); const { finalTokenArray, updatedTokenInfoById, newTokensToCache } = await finalizeTokensArray( chronik, preliminaryTokensArray, cashtabCache.tokenInfoById, ); // If you have more token info now, write this to local storage if (newTokensToCache) { writeTokenInfoByIdToCache(updatedTokenInfoById); // Update the tokenInfoById key in cashtabCache setCashtabCache({ ...cashtabCache, tokenInfoById: updatedTokenInfoById, }); } const finalizedSlpUtxos = finalizeSlpUtxos( preliminarySlpUtxos, updatedTokenInfoById, ); const { chronikTxHistory, txHistoryUpdatedTokenInfoById, txHistoryNewTokensToCache, } = await getTxHistoryChronik( chronik, BCH, wallet, updatedTokenInfoById, ); if (txHistoryNewTokensToCache) { console.log( `Uncached token info found in tx history, adding to cache`, ); writeTokenInfoByIdToCache(txHistoryUpdatedTokenInfoById); // Update the tokenInfoById key in cashtabCache setCashtabCache({ ...cashtabCache, tokenInfoById: txHistoryUpdatedTokenInfoById, }); } // If you were missing any token info for tokens in this tx history, get it const newState = { balances: getWalletBalanceFromUtxos(nonSlpUtxos), tokens: finalTokenArray, slpBalancesAndUtxos: { slpUtxos: finalizedSlpUtxos, nonSlpUtxos, tokens, }, parsedTxHistory: chronikTxHistory, utxos: chronikUtxos, }; // 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 getContactListFromLocalForage = async () => { let contactListArray = []; try { contactListArray = await localforage.getItem('contactList'); } catch (err) { console.log('Error in getContactListFromLocalForage', err); contactListArray = null; } return contactListArray; }; const updateContactList = async contactListArray => { let updateSuccess = true; try { await localforage.setItem('contactList', contactListArray); setContactList(contactListArray); } catch (err) { console.log('Error in updateContactList', err); updateSuccess = false; } return updateSuccess; }; 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 // 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 mnemonic = wallet.mnemonic; const rootSeedBuffer = await bip39.mnemonicToSeed(mnemonic, ''); const masterHDNode = Bitcoin.HDNode.fromSeedBuffer( rootSeedBuffer, coininfo.bitcoincash.main.toBitcoinJS(), ); 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 writeTokenInfoByIdToCache = async tokenInfoById => { console.log(`writeTokenInfoByIdToCache`); const cashtabCache = currency.defaultCashtabCache; cashtabCache.tokenInfoById = tokenInfoById; try { await localforage.setItem('cashtabCache', cashtabCache); console.log(`cashtabCache successfully updated`); } catch (err) { console.log(`Error in writeCashtabCache()`, err); } }; 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 mnemonic = wallet.mnemonic; const rootSeedBuffer = await bip39.mnemonicToSeed(mnemonic, ''); const masterHDNode = Bitcoin.HDNode.fromSeedBuffer( rootSeedBuffer, coininfo.bitcoincash.main.toBitcoinJS(), ); 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 */ console.log(`Activating wallet ${walletToActivate.name}`); setHasUpdated(false); let currentlyActiveWallet; try { //TODO this should just be a param used to call the function currentlyActiveWallet = await localforage.getItem('wallet'); console.log( `Currently active wallet is ${currentlyActiveWallet.name}`, ); } 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. 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 is valid but its stored keyvalue pair in savedWallets is not > Update savedWallets so this saved wallet is valid 2 - walletToActivate is not valid (because it's a legacy saved wallet) > Update walletToActivate before activation */ // Check savedWallets for currentlyActiveWallet let walletInSavedWallets = false; for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === currentlyActiveWallet.name) { walletInSavedWallets = true; // Make sure the savedWallet entry matches the currentlyActiveWallet entry savedWallets[i] = currentlyActiveWallet; console.log( `Updating savedWallet ${savedWallets[i].name} to match state as currentlyActiveWallet ${currentlyActiveWallet.name}`, ); } } // resave savedWallets try { // Set walletName as the active wallet console.log(`Saving updated savedWallets`); 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 // 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 is not in the most up to date Cashtab format`, ); 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; } } // Convert all the token balance figures to big numbers // localforage does not preserve BigNumber type; loadStoredWallet restores BigNumber type const liveWalletState = loadStoredWallet(walletToActivate.state); walletToActivate.state = liveWalletState; console.log(`Returning walletToActivate ${walletToActivate.name}`); return walletToActivate; }; const renameSavedWallet = async (oldName, newName) => { // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in renameSavedWallet`, ); 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 renameSavedWallet()`, ); return false; } return true; }; const renameActiveWallet = async (wallet, oldName, newName) => { // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in renameSavedWallet`, ); 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; } } if (wallet.name === oldName) { wallet.name = newName; setWallet(wallet); } // 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); await localforage.setItem('wallet', wallet); } catch (err) { console.log( `Error in localforage.setItem("wallet", wallet) in renameActiveWallet()`, ); 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 : bip39.generateMnemonic(128, randomBytes, bip39.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 : bip39.generateMnemonic(128, randomBytes, bip39.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 = bip39.wordlists.english) => { - let mnemonicTestOutput; - - // temporary validation of wordlist exclusion - // to be removed in next diff in stack - console.log('english: ' + bip39.wordlists.english); - if ( - !bip39.wordlists.japanese && - !bip39.wordlists.spanish && - !bip39.wordlists.italian && - !bip39.wordlists.french && - !bip39.wordlists.korean && - !bip39.wordlists.czech && - !bip39.wordlists.portuguese && - !bip39.wordlists.chinese_traditional - ) { - console.log( - 'bip39 wordlist is excluding japanese, spanish, italian, french, korean, czech, portuguese and chinese', - ); - } - try { - mnemonicTestOutput = validateMnemonicWordList(mnemonic, wordlist); + if (!mnemonic || !wordlist) return false; - if (mnemonicTestOutput === 'Valid mnemonic') { - return true; - } else { - return false; - } + // Preprocess the words + const words = mnemonic.split(' '); + // Detect blank phrase + if (words.length === 0) return false; + + // Check the words are valid + return bip39.validateMnemonic(mnemonic, wordlist); } catch (err) { console.log(err); return false; } }; // Parse chronik ws message for incoming tx notifications const processChronikWsMsg = async (msg, wallet, fiatPrice) => { // get the message type const { type } = msg; // For now, only act on "first seen" transactions, as the only logic to happen is first seen notifications // Dev note: Other chronik msg types // "BlockConnected", arrives as new blocks are found // "Confirmed", arrives as subscribed + seen txid is confirmed in a block if (type !== 'AddedToMempool') { return; } // If you see a tx from your subscribed addresses added to the mempool, then the wallet utxo set has changed // Update it setWalletRefreshInterval(10); // get txid info const txid = msg.txid; let incomingTxDetails; try { incomingTxDetails = await chronik.tx(txid); } catch (err) { // In this case, no notification return console.log( `Error in chronik.tx(${txid} while processing an incoming websocket tx`, err, ); } // Get tokenInfoById from cashtabCache to parse this tx let tokenInfoById = {}; try { tokenInfoById = cashtabCache.tokenInfoById; } catch (err) { console.log( `Error getting tokenInfoById from cache on incoming tx`, err, ); } // parse tx for notification const parsedChronikTx = parseChronikTx( BCH, incomingTxDetails, wallet, tokenInfoById, ); /* If this is an incoming eToken tx and parseChronikTx was not able to get genesis info from cache, then get genesis info from API and add to cache */ if (parsedChronikTx.incoming) { if (parsedChronikTx.isEtokenTx) { let eTokenAmountReceived = parsedChronikTx.etokenAmount; if (parsedChronikTx.genesisInfo.success) { // Send this info to the notification function eTokenReceivedNotification( currency, parsedChronikTx.genesisInfo.tokenTicker, eTokenAmountReceived, parsedChronikTx.genesisInfo.tokenName, ); } else { // Get genesis info from API and add to cache try { // Get the tokenID const incomingTokenId = parsedChronikTx.slpMeta.tokenId; // chronik call to genesis tx to get this info const tokenGenesisInfo = await chronik.tx( incomingTokenId, ); const { genesisInfo } = tokenGenesisInfo.slpTxData; // Add this to cashtabCache let tokenInfoByIdUpdatedForThisToken = tokenInfoById; tokenInfoByIdUpdatedForThisToken[incomingTokenId] = genesisInfo; writeTokenInfoByIdToCache( tokenInfoByIdUpdatedForThisToken, ); // Update the tokenInfoById key in cashtabCache setCashtabCache({ ...cashtabCache, tokenInfoById: tokenInfoByIdUpdatedForThisToken, }); // Calculate eToken amount with decimals eTokenAmountReceived = new BigNumber( parsedChronikTx.etokenAmount, ).shiftedBy(-1 * genesisInfo.decimals); // Send this info to the notification function eTokenReceivedNotification( currency, genesisInfo.tokenTicker, eTokenAmountReceived, genesisInfo.tokenName, ); } catch (err) { console.log( `Error in getting and setting new token info for incoming eToken tx`, err, ); } } } else { xecReceivedNotificationWebsocket( parsedChronikTx.xecAmount, cashtabSettings, fiatPrice, ); } } }; // Chronik websockets const initializeWebsocket = async (wallet, fiatPrice) => { console.log( `Initializing websocket connection for wallet ${wallet.name}`, ); // Because wallet is set to `false` before it is loaded, do nothing if you find this case // Also return and wait for legacy migration if wallet is not migrated const hash160Array = getHashArrayFromWallet(wallet); if (!wallet || !hash160Array) { return setChronikWebsocket(null); } // Initialize if not in state let ws = chronikWebsocket; if (ws === null) { ws = chronik.ws({ onMessage: msg => { processChronikWsMsg(msg, wallet, fiatPrice); }, onReconnect: e => { // Fired before a reconnect attempt is made: console.log( 'Reconnecting websocket, disconnection cause: ', e, ); }, onConnect: e => { console.log(`Chronik websocket connected`, e); console.log( `Websocket connected, adjusting wallet refresh interval to ${ currency.websocketConnectedRefreshInterval / 1000 }s`, ); setWalletRefreshInterval( currency.websocketConnectedRefreshInterval, ); }, }); // Wait for websocket to be connected: await ws.waitForOpen(); } else { /* If the websocket connection is not null, initializeWebsocket was called because one of the websocket's dependencies changed Update the onMessage method to get the latest dependencies (wallet, fiatPrice) */ ws.onMessage = msg => { processChronikWsMsg(msg, wallet, fiatPrice); }; } // Check if current subscriptions match current wallet let activeSubscriptionsMatchActiveWallet = true; const previousWebsocketSubscriptions = ws._subs; // If there are no previous subscriptions, then activeSubscriptionsMatchActiveWallet is certainly false if (previousWebsocketSubscriptions.length === 0) { activeSubscriptionsMatchActiveWallet = false; } else { const subscribedHash160Array = previousWebsocketSubscriptions.map( function (subscription) { return subscription.scriptPayload; }, ); // Confirm that websocket is subscribed to every address in wallet hash160Array for (let i = 0; i < hash160Array.length; i += 1) { if (!subscribedHash160Array.includes(hash160Array[i])) { activeSubscriptionsMatchActiveWallet = false; } } } // If you are already subscribed to the right addresses, exit here // You get to this situation if fiatPrice changed but wallet.mnemonic did not if (activeSubscriptionsMatchActiveWallet) { // Put connected websocket in state return setChronikWebsocket(ws); } // Unsubscribe to any active subscriptions console.log( `previousWebsocketSubscriptions`, previousWebsocketSubscriptions, ); if (previousWebsocketSubscriptions.length > 0) { for (let i = 0; i < previousWebsocketSubscriptions.length; i += 1) { const unsubHash160 = previousWebsocketSubscriptions[i].scriptPayload; ws.unsubscribe('p2pkh', unsubHash160); console.log(`ws.unsubscribe('p2pkh', ${unsubHash160})`); } } // Subscribe to addresses of current wallet for (let i = 0; i < hash160Array.length; i += 1) { ws.subscribe('p2pkh', hash160Array[i]); console.log(`ws.subscribe('p2pkh', ${hash160Array[i]})`); } // Put connected websocket in state return setChronikWebsocket(ws); }; 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 a settings object is present but invalid, parse to find and add missing keys let modifiedLocalSettings = parseInvalidSettingsForMigration(localSettings); if (isValidCashtabSettings(modifiedLocalSettings)) { // modifiedLocalSettings placed in local storage localforage.setItem('settings', modifiedLocalSettings); setCashtabSettings(modifiedLocalSettings); // update missing key in local storage without overwriting existing valid settings return modifiedLocalSettings; } else { // if not valid, also set cashtabSettings to default setCashtabSettings(currency.defaultSettings); // Since this is returning default settings based on an error from reading storage, do not overwrite whatever is in storage return currency.defaultSettings; } }; const loadContactList = async () => { // get contactList object from localforage let localContactList; try { localContactList = await localforage.getItem('contactList'); // If there is no keyvalue pair in localforage with key 'contactList' if (localContactList === null) { // Use an array containing a single empty object localforage.setItem('contactList', [{}]); setContactList([{}]); return [{}]; } } catch (err) { console.log(`Error getting contactList`, err); setContactList([{}]); return [{}]; } // If you found an object in localforage at the contactList key, make sure it's valid if (isValidContactList(localContactList)) { setContactList(localContactList); return localContactList; } // if not valid, also set to default setContactList([{}]); return [{}]; }; const loadCashtabCache = async () => { // get cache object from localforage let localCashtabCache; try { localCashtabCache = await localforage.getItem('cashtabCache'); // If there is no keyvalue pair in localforage with key 'cashtabCache' if (localCashtabCache === null) { // Use the default localforage.setItem( 'cashtabCache', currency.defaultCashtabCache, ); setCashtabCache(currency.defaultCashtabCache); return currency.defaultCashtabCache; } } catch (err) { console.log(`Error getting cashtabCache`, err); setCashtabCache(currency.defaultCashtabCache); return currency.defaultCashtabCache; } // If you found an object in localforage at the cashtabCache key, make sure it's valid if (isValidCashtabCache(localCashtabCache)) { setCashtabCache(localCashtabCache); return localCashtabCache; } // if not valid, also set to default setCashtabCache(currency.defaultCashtabCache); return currency.defaultCashtabCache; }; // 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 if (key !== 'balanceVisible') { 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 (currency.settingsValidation[key].includes(newValue)) { // Update settings newSettings = currentSettings; newSettings[key] = newValue; } else { // Set fiat price to null, which disables fiat sends throughout the app setFiatPrice(null); // Unlock the UI setLoading(false); return; } // 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 // hasUpdated is set to true in the useInterval function, and re-sets to false during activateWallet // Do not show this notification if websocket connection is live; in this case the websocket will handle it if ( !isActiveWebsocket(chronikWebsocket) && previousBalances && balances && 'totalBalance' in previousBalances && 'totalBalance' in balances && new BigNumber(balances.totalBalance) .minus(previousBalances.totalBalance) .gt(0) && hasUpdated ) { xecReceivedNotification( balances, previousBalances, cashtabSettings, fiatPrice, ); } // Parse for incoming eToken transactions // Do not show this notification if websocket connection is live; in this case the websocket will handle it if ( !isActiveWebsocket(chronikWebsocket) && tokens && tokens[0] && tokens[0].balance && previousTokens && previousTokens[0] && previousTokens[0].balance && hasUpdated === true ) { // 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 according to defined interval useInterval(async () => { const wallet = await getWallet(); update({ wallet, }).finally(() => { setLoading(false); if (!hasUpdated) { setHasUpdated(true); } }); }, walletRefreshInterval); 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); await loadContactList(); await loadCashtabCache(); const initialSettings = await loadCashtabSettings(); initializeFiatPriceApi(initialSettings.fiatCurrency); }, []); /* Run initializeWebsocket(wallet, fiatPrice) each time the wallet or fiatPrice changes Use wallet.mnemonic as the useEffect parameter here because we want to run initializeWebsocket(wallet, fiatPrice) when a new unique wallet is selected, not when the active wallet changes state */ useEffect(async () => { await initializeWebsocket(wallet, fiatPrice); }, [wallet.mnemonic, fiatPrice]); return { BCH, chronik, wallet, fiatPrice, loading, apiError, contactList, cashtabSettings, cashtabCache, changeCashtabSettings, getActiveWalletFromLocalForage, getWallet, validateMnemonic, getWalletDetails, getSavedWallets, migrateLegacyWallet, getContactListFromLocalForage, updateContactList, createWallet: async importMnemonic => { setLoading(true); const newWallet = await createWallet(importMnemonic); setWallet(newWallet); update({ wallet: newWallet, }).finally(() => setLoading(false)); }, activateWallet: async walletToActivate => { setLoading(true); // Make sure that the wallet update interval is not called on the former wallet before this function completes console.log( `Suspending wallet update interval while new wallet is activated`, ); setWalletRefreshInterval( currency.websocketDisconnectedRefreshInterval, ); const newWallet = await activateWallet(walletToActivate); console.log(`activateWallet gives newWallet ${newWallet.name}`); // Changing the wallet here will cause `initializeWebsocket` to fire which will update the websocket interval on a successful connection setWallet(newWallet); // Immediately call update on this wallet to populate it in the latest format // Use the instant interval of 10ms that the update function will cancel setWalletRefreshInterval(10); setLoading(false); }, addNewSavedWallet, renameSavedWallet, renameActiveWallet, deleteWallet, }; }; export default useWallet; diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js index 40af1f26c..7067fb31c 100644 --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -1,757 +1,736 @@ import { shouldRejectAmountInput, fiatToCrypto, isValidTokenName, isValidTokenTicker, isValidTokenDecimals, isValidTokenInitialQty, isValidTokenDocumentUrl, isValidCashtabSettings, isValidXecAddress, isValidBchAddress, isValidNewWalletNameLength, isValidEtokenAddress, isValidXecSendAmount, isValidSendToMany, isValidEtokenBurnAmount, isValidTokenId, isValidXecAirdrop, isValidAirdropOutputsArray, isValidAirdropExclusionArray, isValidContactList, parseInvalidSettingsForMigration, isValidCashtabCache, - validateMnemonicWordList, } from '../validation'; import { currency } from 'components/Common/Ticker.js'; import { fromSatoshisToXec } from 'utils/cashMethods'; import { stStatsValid, noCovidStatsValid, noCovidStatsInvalid, cGenStatsValid, } from '../__mocks__/mockTokenStats'; import { validXecAirdropList, invalidXecAirdropList, invalidXecAirdropListMultipleInvalidValues, invalidXecAirdropListMultipleValidValues, } from '../__mocks__/mockXecAirdropRecipients'; import { validXecAirdropExclusionList, invalidXecAirdropExclusionList, } from '../__mocks__/mockXecAirdropExclusionList'; import { validCashtabCache, cashtabCacheWithOneBadTokenId, cashtabCacheWithDecimalNotNumber, cashtabCacheWithTokenNameNotString, cashtabCacheWithMissingTokenName, } from 'utils/__mocks__/mockCashtabCache'; -import * as bip39 from 'bip39'; describe('Validation utils', () => { - it(`validateMnemonicWordList() returns a success message for a valid mnemonic`, () => { - const validMnemonic = - 'labor tail bulb distance estate collect lecture into smile differ yard legal'; - expect( - validateMnemonicWordList(validMnemonic, bip39.wordlists.english), - ).toBe('Valid mnemonic'); - }); - it(`validateMnemonicWordList() returns an error message for an invalid mnemonic`, () => { - const validMnemonic = - 'labor tail bulb not valid collect lecture into smile differ yard legal'; - expect( - validateMnemonicWordList(validMnemonic, bip39.wordlists.english), - ).toBe('Invalid mnemonic'); - }); - it(`validateMnemonicWordList() returns an error message for an empty mnemonic`, () => { - expect(validateMnemonicWordList('', bip39.wordlists.english)).toBe( - 'Invalid mnemonic', - ); - }); it(`Returns 'false' if ${currency.ticker} send amount is a valid send amount`, () => { expect(shouldRejectAmountInput('10', currency.ticker, 20.0, 300)).toBe( false, ); }); it(`Returns 'false' if ${currency.ticker} send amount is a valid send amount in USD`, () => { // Here, user is trying to send $170 USD, where 1 BCHA = $20 USD, and the user has a balance of 15 BCHA or $300 expect(shouldRejectAmountInput('170', 'USD', 20.0, 15)).toBe(false); }); it(`Returns not a number if ${currency.ticker} send amount is not a number`, () => { const expectedValidationError = `Amount must be a number`; expect( shouldRejectAmountInput('Not a number', currency.ticker, 20.0, 3), ).toBe(expectedValidationError); }); it(`Returns amount must be greater than 0 if ${currency.ticker} send amount is 0`, () => { const expectedValidationError = `Amount must be greater than 0`; expect(shouldRejectAmountInput('0', currency.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns amount must be greater than 0 if ${currency.ticker} send amount is less than 0`, () => { const expectedValidationError = `Amount must be greater than 0`; expect( shouldRejectAmountInput('-0.031', currency.ticker, 20.0, 3), ).toBe(expectedValidationError); }); it(`Returns balance error if ${currency.ticker} send amount is greater than user balance`, () => { const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`; expect(shouldRejectAmountInput('17', currency.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns balance error if ${currency.ticker} send amount is greater than user balance`, () => { const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`; expect(shouldRejectAmountInput('17', currency.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns error if ${ currency.ticker } send amount is less than ${fromSatoshisToXec( currency.dustSats, ).toString()} minimum`, () => { const expectedValidationError = `Send amount must be at least ${fromSatoshisToXec( currency.dustSats, ).toString()} ${currency.ticker}`; expect( shouldRejectAmountInput( ( fromSatoshisToXec(currency.dustSats).toString() - 0.00000001 ).toString(), currency.ticker, 20.0, 3, ), ).toBe(expectedValidationError); }); it(`Returns error if ${ currency.ticker } send amount is less than ${fromSatoshisToXec( currency.dustSats, ).toString()} minimum in fiat currency`, () => { const expectedValidationError = `Send amount must be at least ${fromSatoshisToXec( currency.dustSats, ).toString()} ${currency.ticker}`; expect( shouldRejectAmountInput('0.0000005', 'USD', 0.00005, 1000000), ).toBe(expectedValidationError); }); it(`Returns balance error if ${currency.ticker} send amount is greater than user balance with fiat currency selected`, () => { const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`; // Here, user is trying to send $170 USD, where 1 BCHA = $20 USD, and the user has a balance of 5 BCHA or $100 expect(shouldRejectAmountInput('170', 'USD', 20.0, 5)).toBe( expectedValidationError, ); }); it(`Returns precision error if ${currency.ticker} send amount has more than ${currency.cashDecimals} decimal places`, () => { const expectedValidationError = `${currency.ticker} transactions do not support more than ${currency.cashDecimals} decimal places`; expect( shouldRejectAmountInput('17.123456789', currency.ticker, 20.0, 35), ).toBe(expectedValidationError); }); it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have higher precision`, () => { expect(fiatToCrypto('10.97231694823432', 20.3231342349234234, 8)).toBe( '0.53989295', ); }); it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have higher precision`, () => { expect(fiatToCrypto('10.97231694823432', 20.3231342349234234, 2)).toBe( '0.54', ); }); it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have lower precision`, () => { expect(fiatToCrypto('10.94', 10, 8)).toBe('1.09400000'); }); it(`Accepts a valid ${currency.tokenTicker} token name`, () => { expect(isValidTokenName('Valid token name')).toBe(true); }); it(`Accepts a valid ${currency.tokenTicker} token name that is a stringified number`, () => { expect(isValidTokenName('123456789')).toBe(true); }); it(`Rejects ${currency.tokenTicker} token name if longer than 68 characters`, () => { expect( isValidTokenName( 'This token name is not valid because it is longer than 68 characters which is really pretty long for a token name when you think about it and all', ), ).toBe(false); }); it(`Rejects ${currency.tokenTicker} token name if empty string`, () => { expect(isValidTokenName('')).toBe(false); }); it(`Accepts a 4-char ${currency.tokenTicker} token ticker`, () => { expect(isValidTokenTicker('DOGG')).toBe(true); }); it(`Accepts a 12-char ${currency.tokenTicker} token ticker`, () => { expect(isValidTokenTicker('123456789123')).toBe(true); }); it(`Rejects ${currency.tokenTicker} token ticker if empty string`, () => { expect(isValidTokenTicker('')).toBe(false); }); it(`Rejects ${currency.tokenTicker} token ticker if > 12 chars`, () => { expect(isValidTokenTicker('1234567891234')).toBe(false); }); it(`Accepts ${currency.tokenDecimals} if zero`, () => { expect(isValidTokenDecimals('0')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} if between 0 and 9 inclusive`, () => { expect(isValidTokenDecimals('9')).toBe(true); }); it(`Rejects ${currency.tokenDecimals} if empty string`, () => { expect(isValidTokenDecimals('')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} if non-integer`, () => { expect(isValidTokenDecimals('1.7')).toBe(false); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity at minimum amount for 3 decimal places`, () => { expect(isValidTokenInitialQty('0.001', '3')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity at minimum amount for 9 decimal places`, () => { expect(isValidTokenInitialQty('0.000000001', '9')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity at amount below 100 billion`, () => { expect(isValidTokenInitialQty('1000', '0')).toBe(true); }); it(`Accepts highest possible ${currency.tokenDecimals} initial genesis quantity at amount below 100 billion`, () => { expect(isValidTokenInitialQty('99999999999.999999999', '9')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity if decimal places equal tokenDecimals`, () => { expect(isValidTokenInitialQty('0.123', '3')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity if decimal places are less than tokenDecimals`, () => { expect(isValidTokenInitialQty('0.12345', '9')).toBe(true); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity of zero`, () => { expect(isValidTokenInitialQty('0', '9')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity if tokenDecimals is not valid`, () => { expect(isValidTokenInitialQty('0', '')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity if 100 billion or higher`, () => { expect(isValidTokenInitialQty('100000000000', '0')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity if it has more decimal places than tokenDecimals`, () => { expect(isValidTokenInitialQty('1.5', '0')).toBe(false); }); it(`Accepts a valid ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('cashtabapp.com')).toBe(true); }); it(`Accepts a valid ${currency.tokenTicker} token document URL including special URL characters`, () => { expect(isValidTokenDocumentUrl('https://cashtabapp.com/')).toBe(true); }); it(`Accepts a blank string as a valid ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('')).toBe(true); }); it(`Rejects ${currency.tokenTicker} token name if longer than 68 characters`, () => { expect( isValidTokenDocumentUrl( 'http://www.ThisTokenDocumentUrlIsActuallyMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchTooLong.com/', ), ).toBe(false); }); it(`Accepts a domain input with https protocol as ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('https://google.com')).toBe(true); }); it(`Accepts a domain input with http protocol as ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('http://test.com')).toBe(true); }); it(`Accepts a domain input with a primary and secondary top level domain as ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('http://test.co.uk')).toBe(true); }); it(`Accepts a domain input with just a subdomain as ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('www.test.co.uk')).toBe(true); }); it(`Rejects a domain input with no top level domain, protocol or subdomain ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('mywebsite')).toBe(false); }); it(`Rejects a domain input as numbers ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl(12345)).toBe(false); }); it(`Recognizes the default cashtabCache object as valid`, () => { expect(isValidCashtabCache(currency.defaultCashtabCache)).toBe(true); }); it(`Recognizes a valid cashtabCache object`, () => { expect(isValidCashtabCache(validCashtabCache)).toBe(true); }); it(`Rejects a cashtabCache object if one token id is invalid`, () => { expect(isValidCashtabCache(cashtabCacheWithOneBadTokenId)).toBe(false); }); it(`Rejects a cashtabCache object if decimals is not a number`, () => { expect(isValidCashtabCache(cashtabCacheWithDecimalNotNumber)).toBe( false, ); }); it(`Rejects a cashtabCache object if tokenName is not a string`, () => { expect(isValidCashtabCache(cashtabCacheWithTokenNameNotString)).toBe( false, ); }); it(`Rejects a cashtabCache object if tokenName is missing`, () => { expect(isValidCashtabCache(cashtabCacheWithMissingTokenName)).toBe( false, ); }); it(`Recognizes a valid cashtab settings object`, () => { expect( isValidCashtabSettings({ fiatCurrency: 'usd', sendModal: false, autoCameraOn: true, hideMessagesFromUnknownSenders: true, balanceVisible: false, }), ).toBe(true); }); it(`Rejects a cashtab settings object for an unsupported currency`, () => { expect( isValidCashtabSettings({ fiatCurrency: 'xau', sendModal: false }), ).toBe(false); }); it(`Rejects a corrupted cashtab settings object for an unsupported currency`, () => { expect( isValidCashtabSettings({ fiatCurrencyWrongLabel: 'usd', sendModal: false, }), ).toBe(false); }); it(`Rejects a valid fiatCurrency setting but undefined sendModal setting`, () => { expect(isValidCashtabSettings({ fiatCurrency: 'usd' })).toBe(false); }); it(`Rejects a valid fiatCurrency setting but invalid sendModal setting`, () => { expect( isValidCashtabSettings({ fiatCurrency: 'usd', sendModal: 'NOTVALID', }), ).toBe(false); }); it(`isValidXecAddress correctly validates a valid XEC address with ecash: prefix`, () => { const addr = 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidXecAddress(addr)).toBe(true); }); it(`isValidXecAddress correctly validates a valid XEC address without ecash: prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidXecAddress(addr)).toBe(true); }); it(`isValidXecAddress rejects a valid legacy address`, () => { const addr = '1Efd9z9GRVJK2r73nUpFmBnsKUmfXNm2y2'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid bitcoincash: address`, () => { const addr = 'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid etoken: address with prefix`, () => { const addr = 'etoken:qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid etoken: address without prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid simpleledger: address with prefix`, () => { const addr = 'simpleledger:qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a valid simpleledger: address without prefix`, () => { const addr = 'qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects an invalid address`, () => { const addr = 'wtf is this definitely not an address'; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects a null input`, () => { const addr = null; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidXecAddress rejects an empty string input`, () => { const addr = ''; expect(isValidXecAddress(addr)).toBe(false); }); it(`isValidBchAddress correctly validates a valid BCH address with bitcoincash: prefix`, () => { const addr = 'bitcoincash:qzvydd4n3lm3xv62cx078nu9rg0e3srmqqkm80dnl6'; expect(isValidBchAddress(addr)).toBe(true); }); it(`isValidBchAddress correctly validates a valid BCH address without bitcoincash: prefix`, () => { const addr = 'qzvydd4n3lm3xv62cx078nu9rg0e3srmqqkm80dnl6'; expect(isValidBchAddress(addr)).toBe(true); }); it(`isValidBchAddress rejects a valid legacy address`, () => { const addr = '1Efd9z9GRVJK2r73nUpFmBnsKUmfXNm2y2'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid ecash: address`, () => { const addr = 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid ecash: address without the ecash prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid etoken: address with prefix`, () => { const addr = 'etoken:qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid etoken: address without prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid simpleledger: address with prefix`, () => { const addr = 'simpleledger:qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a valid simpleledger: address without prefix`, () => { const addr = 'qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects an invalid address`, () => { const addr = 'wtf is this definitely not an address'; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects a null input`, () => { const addr = null; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidBchAddress rejects an empty string input`, () => { const addr = ''; expect(isValidBchAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid XEC address with ecash: prefix`, () => { const addr = 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid XEC address without ecash: prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid legacy address`, () => { const addr = '1Efd9z9GRVJK2r73nUpFmBnsKUmfXNm2y2'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid bitcoincash: address`, () => { const addr = 'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress correctly validates a valid etoken: address with prefix`, () => { const addr = 'etoken:qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidEtokenAddress(addr)).toBe(true); }); it(`isValidEtokenAddress correctly validates a valid etoken: address without prefix`, () => { const addr = 'qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'; expect(isValidEtokenAddress(addr)).toBe(true); }); it(`isValidEtokenAddress rejects a valid simpleledger: address with prefix`, () => { const addr = 'simpleledger:qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a valid simpleledger: address without prefix`, () => { const addr = 'qrujw0wrzncyxw8q3d0xkfet4jafrqhk6csev0v6y3'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects an invalid address`, () => { const addr = 'wtf is this definitely not an address'; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects a null input`, () => { const addr = null; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidEtokenAddress rejects an empty string input`, () => { const addr = ''; expect(isValidEtokenAddress(addr)).toBe(false); }); it(`isValidXecSendAmount accepts the dust minimum`, () => { const testXecSendAmount = fromSatoshisToXec(currency.dustSats); expect(isValidXecSendAmount(testXecSendAmount)).toBe(true); }); it(`isValidXecSendAmount accepts arbitrary number above dust minimum`, () => { const testXecSendAmount = fromSatoshisToXec(currency.dustSats) + 1.75; expect(isValidXecSendAmount(testXecSendAmount)).toBe(true); }); it(`isValidXecSendAmount rejects zero`, () => { const testXecSendAmount = 0; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidXecSendAmount rejects a non-number string`, () => { const testXecSendAmount = 'not a number'; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidXecSendAmount accepts arbitrary number above dust minimum as a string`, () => { const testXecSendAmount = `${ fromSatoshisToXec(currency.dustSats) + 1.75 }`; expect(isValidXecSendAmount(testXecSendAmount)).toBe(true); }); it(`isValidXecSendAmount rejects null`, () => { const testXecSendAmount = null; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidXecSendAmount rejects undefined`, () => { const testXecSendAmount = undefined; expect(isValidXecSendAmount(testXecSendAmount)).toBe(false); }); it(`isValidEtokenBurnAmount rejects null`, () => { const testEtokenBurnAmount = null; expect(isValidEtokenBurnAmount(testEtokenBurnAmount)).toBe(false); }); it(`isValidEtokenBurnAmount rejects undefined`, () => { const testEtokenBurnAmount = undefined; expect(isValidEtokenBurnAmount(testEtokenBurnAmount)).toBe(false); }); it(`isValidEtokenBurnAmount rejects a burn amount that is 0`, () => { const testEtokenBurnAmount = 0; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(false); }); it(`isValidEtokenBurnAmount rejects a burn amount that is negative`, () => { const testEtokenBurnAmount = -50; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(false); }); it(`isValidEtokenBurnAmount rejects a burn amount that is more than the maxAmount param`, () => { const testEtokenBurnAmount = 1000; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(false); }); it(`isValidEtokenBurnAmount accepts a valid burn amount`, () => { const testEtokenBurnAmount = 50; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(true); }); it(`isValidEtokenBurnAmount accepts a valid burn amount with decimal points`, () => { const testEtokenBurnAmount = 10.545454; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(true); }); it(`isValidEtokenBurnAmount accepts a valid burn amount that is the same as the maxAmount`, () => { const testEtokenBurnAmount = 100; expect(isValidEtokenBurnAmount(testEtokenBurnAmount, 100)).toBe(true); }); it(`isValidTokenId accepts valid token ID that is 64 chars in length`, () => { const testValidTokenId = '1c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e'; expect(isValidTokenId(testValidTokenId)).toBe(true); }); it(`isValidTokenId rejects a token ID that is less than 64 chars in length`, () => { const testValidTokenId = '111111thisisaninvalidtokenid'; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects a token ID that is more than 64 chars in length`, () => { const testValidTokenId = '111111111c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e'; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects a token ID number that is 64 digits in length`, () => { const testValidTokenId = 8912345678912345678912345678912345678912345678912345678912345679; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects null`, () => { const testValidTokenId = null; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects undefined`, () => { const testValidTokenId = undefined; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects empty string`, () => { const testValidTokenId = ''; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects special character input`, () => { const testValidTokenId = '^&$%@&^$@&%$!'; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidTokenId rejects non-alphanumeric input`, () => { const testValidTokenId = 99999999999; expect(isValidTokenId(testValidTokenId)).toBe(false); }); it(`isValidXecAirdrop accepts valid Total Airdrop Amount`, () => { const testAirdropTotal = '1000000'; expect(isValidXecAirdrop(testAirdropTotal)).toBe(true); }); it(`isValidXecAirdrop rejects null`, () => { const testAirdropTotal = null; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidXecAirdrop rejects undefined`, () => { const testAirdropTotal = undefined; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidXecAirdrop rejects empty string`, () => { const testAirdropTotal = ''; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidTokenId rejects an alphanumeric input`, () => { const testAirdropTotal = 'a73hsyujs3737'; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidTokenId rejects a number !> 0 in string format`, () => { const testAirdropTotal = '0'; expect(isValidXecAirdrop(testAirdropTotal)).toBe(false); }); it(`isValidAirdropOutputsArray accepts an airdrop list with valid XEC values`, () => { // Tools.js logic removes the EOF newline before validation const outputArray = validXecAirdropList.substring( 0, validXecAirdropList.length - 1, ); expect(isValidAirdropOutputsArray(outputArray)).toBe(true); }); it(`isValidAirdropOutputsArray rejects an airdrop list with invalid XEC values`, () => { // Tools.js logic removes the EOF newline before validation const outputArray = invalidXecAirdropList.substring( 0, invalidXecAirdropList.length - 1, ); expect(isValidAirdropOutputsArray(outputArray)).toBe(false); }); it(`isValidAirdropOutputsArray rejects null`, () => { const testAirdropListValues = null; expect(isValidAirdropOutputsArray(testAirdropListValues)).toBe(false); }); it(`isValidAirdropOutputsArray rejects undefined`, () => { const testAirdropListValues = undefined; expect(isValidAirdropOutputsArray(testAirdropListValues)).toBe(false); }); it(`isValidAirdropOutputsArray rejects empty string`, () => { const testAirdropListValues = ''; expect(isValidAirdropOutputsArray(testAirdropListValues)).toBe(false); }); it(`isValidAirdropOutputsArray rejects an airdrop list with multiple invalid XEC values per row`, () => { // Tools.js logic removes the EOF newline before validation const addressStringArray = invalidXecAirdropListMultipleInvalidValues.substring( 0, invalidXecAirdropListMultipleInvalidValues.length - 1, ); expect(isValidAirdropOutputsArray(addressStringArray)).toBe(false); }); it(`isValidAirdropOutputsArray rejects an airdrop list with multiple valid XEC values per row`, () => { // Tools.js logic removes the EOF newline before validation const addressStringArray = invalidXecAirdropListMultipleValidValues.substring( 0, invalidXecAirdropListMultipleValidValues.length - 1, ); expect(isValidAirdropOutputsArray(addressStringArray)).toBe(false); }); it(`isValidAirdropExclusionArray accepts a valid airdrop exclusion list`, () => { expect(isValidAirdropExclusionArray(validXecAirdropExclusionList)).toBe( true, ); }); it(`isValidAirdropExclusionArray rejects an invalid airdrop exclusion list`, () => { expect( isValidAirdropExclusionArray(invalidXecAirdropExclusionList), ).toBe(false); }); it(`isValidAirdropExclusionArray rejects an empty airdrop exclusion list`, () => { expect(isValidAirdropExclusionArray([])).toBe(false); }); it(`isValidAirdropExclusionArray rejects a null airdrop exclusion list`, () => { expect(isValidAirdropExclusionArray(null)).toBe(false); }); it(`isValidContactList accepts default empty contactList`, () => expect(isValidContactList([{}])).toBe(true)); it(`isValidContactList rejects array of more than one empty object`, () => expect(isValidContactList([{}, {}])).toBe(false)); it(`isValidContactList accepts a contact list of length 1 with valid XEC address and name`, () => expect( isValidContactList([ { address: 'ecash:qphlhe78677sz227k83hrh542qeehh8el5lcjwk72y', name: 'Alpha', }, ]), ).toBe(true)); it(`isValidContactList accepts a contact list of length > 1 with valid XEC addresses and names`, () => expect( isValidContactList([ { address: 'ecash:qpdkc5p7f25hwkxsr69m3evlj4h7wqq9xcgmjc8sxr', name: 'Alpha', }, { address: 'ecash:qpq235n3l3u6ampc8slapapnatwfy446auuv64ylt2', name: 'Beta', }, { address: 'ecash:qz50e58nkeg2ej2f34z6mhwylp6ven8emy8pp52r82', name: 'Gamma', }, ]), ).toBe(true)); it(`isValidContactList rejects a contact list of length > 1 with valid XEC addresses and names but an empty object included`, () => expect( isValidContactList([ {}, { address: 'ecash:qpdkc5p7f25hwkxsr69m3evlj4h7wqq9xcgmjc8sxr', name: 'Alpha', }, { address: 'ecash:qpq235n3l3u6ampc8slapapnatwfy446auuv64ylt2', name: 'Beta', }, { address: 'ecash:qz50e58nkeg2ej2f34z6mhwylp6ven8emy8pp52r82', name: 'Gamma', }, ]), ).toBe(false)); it('updates an invalid settings object and keeps existing valid settings intact', () => expect( parseInvalidSettingsForMigration({ fiatCurrency: 'gbp', }), ).toStrictEqual({ fiatCurrency: 'gbp', sendModal: false, autoCameraOn: true, hideMessagesFromUnknownSenders: false, balanceVisible: true, })); it('sets settings object with no exsting valid settings to default values', () => expect(parseInvalidSettingsForMigration({})).toStrictEqual({ fiatCurrency: 'usd', sendModal: false, autoCameraOn: true, hideMessagesFromUnknownSenders: false, balanceVisible: true, })); it('does nothing if valid settings object is present in localStorage', () => expect( parseInvalidSettingsForMigration({ fiatCurrency: 'brl', sendModal: true, autoCameraOn: true, hideMessagesFromUnknownSenders: false, balanceVisible: true, }), ).toStrictEqual({ fiatCurrency: 'brl', sendModal: true, autoCameraOn: true, hideMessagesFromUnknownSenders: false, balanceVisible: true, })); it(`accepts a valid wallet name`, () => { expect(isValidNewWalletNameLength('Apollo')).toBe(true); }); it(`rejects wallet name that is too long`, () => { expect( isValidNewWalletNameLength( 'this string is far too long to be used as a wallet name...', ), ).toBe(false); }); it(`rejects blank string as new wallet name`, () => { expect(isValidNewWalletNameLength('')).toBe(false); }); it(`rejects wallet name of the wrong type`, () => { expect(isValidNewWalletNameLength(['newWalletName'])).toBe(false); }); }); diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js index 965298fad..0d586c763 100644 --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -1,598 +1,583 @@ import BigNumber from 'bignumber.js'; import { currency } from 'components/Common/Ticker.js'; import { fromSatoshisToXec } from 'utils/cashMethods'; import cashaddr from 'ecashaddrjs'; -import * as bip39 from 'bip39'; - -// reference: https://github.com/Permissionless-Software-Foundation/bch-js/blob/62e56c832b35731880fe448269818b853c76dd80/src/mnemonic.js#L160-L180 -export const validateMnemonicWordList = (mnemonic, wordlist) => { - // Preprocess the words - const words = mnemonic.split(' '); - // Detect blank phrase - if (words.length === 0) return 'Blank mnemonic'; - - // Check the words are valid - const isValid = bip39.validateMnemonic(mnemonic, wordlist); - if (!isValid) return 'Invalid mnemonic'; - - return 'Valid mnemonic'; -}; // Validate cash amount export const shouldRejectAmountInput = ( cashAmount, selectedCurrency, fiatPrice, totalCashBalance, ) => { // Take cashAmount as input, a string from form input let error = false; let testedAmount = new BigNumber(cashAmount); if (selectedCurrency !== currency.ticker) { // Ensure no more than currency.cashDecimals decimal places testedAmount = new BigNumber(fiatToCrypto(cashAmount, fiatPrice)); } // Validate value for > 0 if (isNaN(testedAmount)) { error = 'Amount must be a number'; } else if (testedAmount.lte(0)) { error = 'Amount must be greater than 0'; } else if (testedAmount.lt(fromSatoshisToXec(currency.dustSats))) { error = `Send amount must be at least ${fromSatoshisToXec( currency.dustSats, ).toString()} ${currency.ticker}`; } else if (testedAmount.gt(totalCashBalance)) { error = `Amount cannot exceed your ${currency.ticker} balance`; } else if (!isNaN(testedAmount) && testedAmount.toString().includes('.')) { if ( testedAmount.toString().split('.')[1].length > currency.cashDecimals ) { error = `${currency.ticker} transactions do not support more than ${currency.cashDecimals} decimal places`; } } // return false if no error, or string error msg if error return error; }; export const fiatToCrypto = ( fiatAmount, fiatPrice, cashDecimals = currency.cashDecimals, ) => { let cryptoAmount = new BigNumber(fiatAmount) .div(new BigNumber(fiatPrice)) .toFixed(cashDecimals); return cryptoAmount; }; export const isProbablyNotAScamTokenName = tokenName => { // convert to lower case, trim leading and trailing spaces // split, filter then join on ' ' for cases where user inputs multiple spaces const sanitizedTokenName = tokenName .toLowerCase() .trim() .split(' ') .filter(string => string) .join(' '); return ( !currency.coingeckoTop500Names.includes(sanitizedTokenName) && // for cases where user adds spaces between e a c h letter !currency.coingeckoTop500Names.includes( sanitizedTokenName.split(' ').join(''), ) && // cross reference with coingeckoTop500Tickers !currency.coingeckoTop500Tickers.includes(sanitizedTokenName) && !currency.coingeckoTop500Tickers.includes( sanitizedTokenName.split(' ').join(''), ) && //cross reference with coingeckoTop500Ids !currency.coingeckoTop500Ids.includes(sanitizedTokenName) && !currency.coingeckoTop500Ids.includes( sanitizedTokenName.split(' ').join(''), ) && //cross reference with bannedFiatCurrencies !currency.settingsValidation.fiatCurrency.includes( sanitizedTokenName, ) && !currency.settingsValidation.fiatCurrency.includes( sanitizedTokenName.split(' ').join(''), ) && //cross reference with bannedTickers !currency.bannedTickers.includes(sanitizedTokenName) && !currency.bannedTickers.includes( sanitizedTokenName.split(' ').join(''), ) && //cross reference with bannedNames !currency.bannedNames.includes(sanitizedTokenName) && !currency.bannedNames.includes(sanitizedTokenName.split(' ').join('')) ); }; export const isProbablyNotAScamTokenTicker = tokenTicker => { // convert to lower case, trim leading and trailing spaces // split, filter then join on ' ' for cases where user inputs multiple spaces const sanitizedTokenTicker = tokenTicker .toLowerCase() .trim() .split(' ') .filter(string => string) .join(''); return ( !currency.coingeckoTop500Tickers.includes(sanitizedTokenTicker) && // for cases where user adds spaces between e a c h letter !currency.coingeckoTop500Tickers.includes( sanitizedTokenTicker.split(' ').join(''), ) && //cross reference with coingeckoTop500Names !currency.coingeckoTop500Names.includes(sanitizedTokenTicker) && !currency.coingeckoTop500Names.includes( sanitizedTokenTicker.split(' ').join(''), ) && //cross reference with coingeckoTop500Ids !currency.coingeckoTop500Ids.includes(sanitizedTokenTicker) && !currency.coingeckoTop500Ids.includes( sanitizedTokenTicker.split(' ').join(''), ) && //cross reference with bannedFiatCurrencies !currency.settingsValidation.fiatCurrency.includes( sanitizedTokenTicker, ) && !currency.settingsValidation.fiatCurrency.includes( sanitizedTokenTicker.split(' ').join(''), ) && //cross reference with bannedTickers !currency.bannedTickers.includes(sanitizedTokenTicker) && !currency.bannedTickers.includes( sanitizedTokenTicker.split(' ').join(''), ) && //cross reference with bannedNames !currency.bannedNames.includes(sanitizedTokenTicker) && !currency.bannedNames.includes(sanitizedTokenTicker.split(' ').join('')) ); }; export const isValidTokenName = tokenName => { return ( typeof tokenName === 'string' && tokenName.length > 0 && tokenName.length < 68 ); }; export const isValidTokenTicker = tokenTicker => { return ( typeof tokenTicker === 'string' && tokenTicker.length > 0 && tokenTicker.length < 13 ); }; export const isValidTokenDecimals = tokenDecimals => { return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes( tokenDecimals, ); }; export const isValidTokenInitialQty = (tokenInitialQty, tokenDecimals) => { const minimumQty = new BigNumber(1 / 10 ** tokenDecimals); const tokenIntialQtyBig = new BigNumber(tokenInitialQty); return ( tokenIntialQtyBig.gte(minimumQty) && tokenIntialQtyBig.lt(100000000000) && tokenIntialQtyBig.dp() <= tokenDecimals ); }; export const isValidTokenDocumentUrl = tokenDocumentUrl => { const urlPattern = new RegExp( '^(https?:\\/\\/)?' + // protocol '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string '(\\#[-a-z\\d_]*)?$', 'i', ); // fragment locator const urlTestResult = urlPattern.test(tokenDocumentUrl); return ( tokenDocumentUrl === '' || (typeof tokenDocumentUrl === 'string' && tokenDocumentUrl.length >= 0 && tokenDocumentUrl.length < 68 && urlTestResult) ); }; export const isValidCashtabSettings = settings => { try { let isValidSettingParams = true; for (let param in currency.defaultSettings) { if ( !Object.prototype.hasOwnProperty.call(settings, param) || !currency.settingsValidation[param].includes(settings[param]) ) { isValidSettingParams = false; break; } } const isValid = typeof settings === 'object' && isValidSettingParams; return isValid; } catch (err) { return false; } }; export const parseInvalidSettingsForMigration = invalidCashtabSettings => { // create a copy of the invalidCashtabSettings let migratedCashtabSettings = invalidCashtabSettings; // determine if settings are invalid because it is missing a parameter for (let param in currency.defaultSettings) { if ( !Object.prototype.hasOwnProperty.call(invalidCashtabSettings, param) ) { // adds the default setting for only that parameter migratedCashtabSettings[param] = currency.defaultSettings[param]; } } return migratedCashtabSettings; }; export const isValidContactList = contactList => { /* A valid contact list is an array of objects An empty contact list looks like [{}] Although a valid contact list does not contain duplicated addresses, this is not checked here. This is checked for when contacts are added. Duplicate addresses will not break the app if a user somehow sideloads a contact list with everything valid except some addresses are duplicated. */ if (!Array.isArray(contactList)) { return false; } for (let i = 0; i < contactList.length; i += 1) { const contactObj = contactList[i]; // Must have keys 'address' and 'name' if ( typeof contactObj === 'object' && 'address' in contactObj && 'name' in contactObj ) { // Address must be a valid XEC address, name must be a string if ( isValidXecAddress(contactObj.address) && typeof contactObj.name === 'string' ) { continue; } return false; } else { // Check for empty object in an array of length 1, the default blank contactList if ( contactObj && Object.keys(contactObj).length === 0 && Object.getPrototypeOf(contactObj) === Object.prototype && contactList.length === 1 ) { // [{}] is valid, default blank // But a list with random blanks is not valid return true; } return false; } } // If you get here, it's good return true; }; export const isValidCashtabCache = cashtabCache => { /* Object must contain all keys listed in currency.defaultCashtabCache The tokenInfoById object must have keys that are valid token IDs, and at each one an object like: { "tokenTicker": "ST", "tokenName": "ST", "tokenDocumentUrl": "developer.bitcoin.com", "tokenDocumentHash": "", "decimals": 0, "tokenId": "bf24d955f59351e738ecd905966606a6837e478e1982943d724eab10caad82fd" } i.e. an object that contains these keys 'tokenTicker' is a string 'tokenName' is a string 'tokenDocumentUrl' is a string 'tokenDocumentHash' is a string 'decimals' is a number 'tokenId' is a valid tokenId */ // Check that every key in currency.defaultCashtabCache is also in this cashtabCache const cashtabCacheKeys = Object.keys(currency.defaultCashtabCache); for (let i = 0; i < cashtabCacheKeys.length; i += 1) { const thisKey = cashtabCacheKeys[i]; if (thisKey in cashtabCache) { continue; } return false; } // Check that tokenInfoById is expected type and that tokenIds are valid const { tokenInfoById } = cashtabCache; const tokenIds = Object.keys(tokenInfoById); for (let i = 0; i < tokenIds.length; i += 1) { const thisTokenId = tokenIds[i]; if (!isValidTokenId(thisTokenId)) { return false; } const { tokenTicker, tokenName, tokenDocumentUrl, tokenDocumentHash, decimals, tokenId, } = tokenInfoById[thisTokenId]; if ( typeof tokenTicker !== 'string' || typeof tokenName !== 'string' || typeof tokenDocumentUrl !== 'string' || typeof tokenDocumentHash !== 'string' || typeof decimals !== 'number' || !isValidTokenId(tokenId) ) { return false; } } return true; }; export const isValidXecAddress = addr => { /* Returns true for a valid XEC address Valid XEC address: - May or may not have prefix `ecash:` - Checksum must validate for prefix `ecash:` An eToken address is not considered a valid XEC address */ if (!addr) { return false; } let isValidXecAddress; let isPrefixedXecAddress; // Check for possible prefix if (addr.includes(':')) { // Test for 'ecash:' prefix isPrefixedXecAddress = addr.slice(0, 6) === 'ecash:'; // Any address including ':' that doesn't start explicitly with 'ecash:' is invalid if (!isPrefixedXecAddress) { isValidXecAddress = false; return isValidXecAddress; } } else { isPrefixedXecAddress = false; } // If no prefix, assume it is checksummed for an ecash: prefix const testedXecAddr = isPrefixedXecAddress ? addr : `ecash:${addr}`; try { const decoded = cashaddr.decode(testedXecAddr); if (decoded.prefix === 'ecash') { isValidXecAddress = true; } } catch (err) { isValidXecAddress = false; } return isValidXecAddress; }; export const isValidBchAddress = addr => { /* Returns true for a valid BCH address Valid BCH address: - May or may not have prefix `bitcoincash:` - Checksum must validate for prefix `bitcoincash:` A simple ledger address is not considered a valid bitcoincash address */ if (!addr) { return false; } let isValidBchAddress; let isPrefixedBchAddress; // Check for possible prefix if (addr.includes(':')) { // Test for 'ecash:' prefix isPrefixedBchAddress = addr.slice(0, 12) === 'bitcoincash:'; // Any address including ':' that doesn't start explicitly with 'bitcoincash:' is invalid if (!isPrefixedBchAddress) { isValidBchAddress = false; return isValidBchAddress; } } else { isPrefixedBchAddress = false; } // If no prefix, assume it is checksummed for an bitcoincash: prefix const testedXecAddr = isPrefixedBchAddress ? addr : `bitcoincash:${addr}`; try { const decoded = cashaddr.decode(testedXecAddr); if (decoded.prefix === 'bitcoincash') { isValidBchAddress = true; } } catch (err) { isValidBchAddress = false; } return isValidBchAddress; }; export const isValidEtokenAddress = addr => { /* Returns true for a valid eToken address Valid eToken address: - May or may not have prefix `etoken:` - Checksum must validate for prefix `etoken:` An XEC address is not considered a valid eToken address */ if (!addr) { return false; } let isValidEtokenAddress; let isPrefixedEtokenAddress; // Check for possible prefix if (addr.includes(':')) { // Test for 'etoken:' prefix isPrefixedEtokenAddress = addr.slice(0, 7) === 'etoken:'; // Any token address including ':' that doesn't start explicitly with 'etoken:' is invalid if (!isPrefixedEtokenAddress) { isValidEtokenAddress = false; return isValidEtokenAddress; } } else { isPrefixedEtokenAddress = false; } // If no prefix, assume it is checksummed for an etoken: prefix const testedEtokenAddr = isPrefixedEtokenAddress ? addr : `etoken:${addr}`; try { const decoded = cashaddr.decode(testedEtokenAddr); if (decoded.prefix === 'etoken') { isValidEtokenAddress = true; } } catch (err) { isValidEtokenAddress = false; } return isValidEtokenAddress; }; export const isValidXecSendAmount = xecSendAmount => { // A valid XEC send amount must be a number higher than the app dust limit return ( xecSendAmount !== null && typeof xecSendAmount !== 'undefined' && !isNaN(parseFloat(xecSendAmount)) && parseFloat(xecSendAmount) >= fromSatoshisToXec(currency.dustSats).toNumber() ); }; export const isValidEtokenBurnAmount = (tokenBurnAmount, maxAmount) => { // A valid eToken burn amount must be between 1 and the wallet's token balance return ( tokenBurnAmount !== null && maxAmount !== null && typeof tokenBurnAmount !== 'undefined' && typeof maxAmount !== 'undefined' && new BigNumber(tokenBurnAmount).gt(0) && new BigNumber(tokenBurnAmount).lte(maxAmount) ); }; // XEC airdrop field validations export const isValidTokenId = tokenId => { // disable no-useless-escape for regex //eslint-disable-next-line const format = /[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/; const specialCharCheck = format.test(tokenId); return ( typeof tokenId === 'string' && tokenId.length === 64 && tokenId.trim() != '' && !specialCharCheck ); }; export const isValidNewWalletNameLength = newWalletName => { return ( typeof newWalletName === 'string' && newWalletName.length > 0 && newWalletName.length <= currency.localStorageMaxCharacters && newWalletName.length !== '' ); }; export const isValidXecAirdrop = xecAirdrop => { return ( typeof xecAirdrop === 'string' && xecAirdrop.length > 0 && xecAirdrop.trim() != '' && new BigNumber(xecAirdrop).gt(0) ); }; export const isValidAirdropOutputsArray = airdropOutputsArray => { if (!airdropOutputsArray) { return false; } let isValid = true; // split by individual rows const addressStringArray = airdropOutputsArray.split('\n'); for (let i = 0; i < addressStringArray.length; i++) { const substring = addressStringArray[i].split(','); let valueString = substring[1]; // if the XEC being sent is less than dust sats or contains extra values per line if ( new BigNumber(valueString).lt( fromSatoshisToXec(currency.dustSats), ) || substring.length !== 2 ) { isValid = false; } } return isValid; }; export const isValidAirdropExclusionArray = airdropExclusionArray => { if (!airdropExclusionArray || airdropExclusionArray.length === 0) { return false; } let isValid = true; // split by comma as the delimiter const addressStringArray = airdropExclusionArray.split(','); // parse and validate each address in array for (let i = 0; i < addressStringArray.length; i++) { if (!isValidXecAddress(addressStringArray[i])) { return false; } } return isValid; };