diff --git a/web/cashtab/src/components/Home/Home.js b/web/cashtab/src/components/Home/Home.js index 1fc956942..90359707d 100644 --- a/web/cashtab/src/components/Home/Home.js +++ b/web/cashtab/src/components/Home/Home.js @@ -1,257 +1,259 @@ import React from 'react'; import styled from 'styled-components'; import { WalletContext } from 'utils/context'; import OnBoarding from 'components/OnBoarding/OnBoarding'; import { currency } from 'components/Common/Ticker.js'; import { Link } from 'react-router-dom'; import TokenList from './TokenList'; import TxHistory from './TxHistory'; import ApiError from 'components/Common/ApiError'; import BalanceHeader from 'components/Common/BalanceHeader'; import BalanceHeaderFiat from 'components/Common/BalanceHeaderFiat'; import { LoadingCtn, WalletInfoCtn, SidePaddingCtn, } from 'components/Common/Atoms'; import { getWalletState } from 'utils/cashMethods'; import WalletLabel from 'components/Common/WalletLabel.js'; export const Tabs = styled.div` margin: auto; display: inline-block; text-align: center; width: 100%; margin: 20px 0; `; export const TabLabel = styled.button` :focus, :active { outline: none; } color: ${props => props.theme.lightWhite}; border: none; background: none; font-size: 18px; cursor: pointer; margin: 0 20px; padding: 0; @media (max-width: 400px) { font-size: 16px; } ${({ active, ...props }) => active && ` color: ${props.theme.contrast}; border-bottom: 2px solid ${props.theme.eCashBlue} `} ${({ token, ...props }) => token && ` border-color:${props.theme.eCashPurple} `} `; export const TabPane = styled.div` color: ${props => props.theme.contrast}; ${({ active }) => !active && ` display: none; `} `; export const Links = styled(Link)` color: ${props => props.theme.darkBlue}; width: 100%; font-size: 16px; margin: 10px 0 20px 0; border: 1px solid ${props => props.theme.darkBlue}; padding: 14px 0; display: inline-block; border-radius: 3px; transition: all 200ms ease-in-out; svg { fill: ${props => props.theme.darkBlue}; } :hover { color: ${props => props.theme.eCashBlue}; border-color: ${props => props.theme.eCashBlue}; svg { fill: ${props => props.theme.eCashBlue}; } } @media (max-width: 768px) { padding: 10px 0; font-size: 14px; } `; export const ExternalLink = styled.a` color: ${props => props.theme.darkBlue}; width: 100%; font-size: 16px; margin: 0 0 20px 0; border: 1px solid ${props => props.theme.darkBlue}; padding: 14px 0; display: inline-block; border-radius: 3px; transition: all 200ms ease-in-out; svg { fill: ${props => props.theme.darkBlue}; transition: all 200ms ease-in-out; } :hover { color: ${props => props.theme.eCashBlue}; border-color: ${props => props.theme.eCashBlue}; svg { fill: ${props => props.theme.eCashBlue}; } } @media (max-width: 768px) { padding: 10px 0; font-size: 14px; } `; export const AddrSwitchContainer = styled.div` text-align: center; padding: 6px 0 12px 0; `; const CreateToken = styled(Link)` color: ${props => props.theme.contrast}; border: 1px solid ${props => props.theme.contrast}; padding: 8px 15px; border-radius: 5px; margin-top: 10px; margin-bottom: 20px; display: inline-block; width: 100%; :hover { background: ${props => props.theme.eCashPurple}; border-color: ${props => props.theme.eCashPurple}; color: ${props => props.theme.contrast}; } `; const WalletInfo = () => { const ContextValue = React.useContext(WalletContext); - const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; + const { wallet, fiatPrice, apiError, cashtabSettings, contactList } = + ContextValue; const walletState = getWalletState(wallet); const { balances, parsedTxHistory, tokens } = walletState; const [activeTab, setActiveTab] = React.useState('txHistory'); const hasHistory = parsedTxHistory && parsedTxHistory.length > 0; return ( <> {apiError && } setActiveTab('txHistory')} > Transactions setActiveTab('tokens')} > eTokens {!hasHistory && ( <> 🎉 Congratulations on your new wallet!{' '} 🎉
Start using the wallet immediately to receive{' '} {currency.ticker} payments, or load it up with{' '} {currency.ticker} to send to others )}
Create eToken {tokens && tokens.length > 0 ? ( ) : (

Tokens sent to your {currency.tokenTicker} address will appear here

)}
); }; const Home = () => { const ContextValue = React.useContext(WalletContext); const { wallet, previousWallet, loading } = ContextValue; return ( <> {loading ? ( ) : ( <> {(wallet && wallet.Path1899) || (previousWallet && previousWallet.path1899) ? ( ) : ( )} )} ); }; export default Home; diff --git a/web/cashtab/src/components/Home/Tx.js b/web/cashtab/src/components/Home/Tx.js index ffb000085..a419053fc 100644 --- a/web/cashtab/src/components/Home/Tx.js +++ b/web/cashtab/src/components/Home/Tx.js @@ -1,754 +1,774 @@ import React from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import { SendIcon, ReceiveIcon, GenesisIcon, UnparsedIcon, ThemedContactsOutlined, } from 'components/Common/CustomIcons'; import { currency } from 'components/Common/Ticker'; import { fromLegacyDecimals } from 'utils/cashMethods'; import { formatBalance, formatDate } from 'utils/formatting'; import TokenIcon from 'components/Tokens/TokenIcon'; import { Collapse } from 'antd'; import { generalNotification } from 'components/Common/Notifications'; import { CopyToClipboard } from 'react-copy-to-clipboard'; import { ThemedCopySolid, ThemedLinkSolid, } from 'components/Common/CustomIcons'; const TxIcon = styled.div` svg { width: 20px; height: 20px; } height: 40px; width: 40px; border: 1px solid #fff; display: flex; align-items: center; justify-content: center; border-radius: 100px; `; const AddToContacts = styled.span` max-height: 200px; text-align: left; `; const SentTx = styled(TxIcon)` svg { margin-right: -3px; } fill: ${props => props.theme.contrast}; `; const ReceivedTx = styled(TxIcon)` svg { fill: ${props => props.theme.eCashBlue}; } border-color: ${props => props.theme.eCashBlue}; `; const GenesisTx = styled(TxIcon)` border-color: ${props => props.theme.genesisGreen}; svg { fill: ${props => props.theme.genesisGreen}; } `; const UnparsedTx = styled(TxIcon)` color: ${props => props.theme.eCashBlue} !important; `; const DateType = styled.div` text-align: left; padding: 12px; @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const LeftTextCtn = styled.div` text-align: left; display: flex; align-items: left; flex-direction: column; margin-left: 10px; h3 { color: ${props => props.theme.contrast}; font-size: 14px; font-weight: 700; margin: 0; } .genesis { color: ${props => props.theme.genesisGreen}; } .received { color: ${props => props.theme.eCashBlue}; } h4 { font-size: 12px; color: ${props => props.theme.lightWhite}; margin: 0; } `; const RightTextCtn = styled.div` text-align: right; display: flex; align-items: left; flex-direction: column; margin-left: 10px; h3 { color: ${props => props.theme.contrast}; font-size: 14px; font-weight: 700; margin: 0; } .genesis { color: ${props => props.theme.genesisGreen}; } .received { color: ${props => props.theme.eCashBlue}; } h4 { font-size: 12px; color: ${props => props.theme.lightWhite}; margin: 0; } `; const OpReturnType = styled.div` text-align: right; width: 100%; padding: 10px; border-radius: 5px; background: ${props => props.theme.sentMessage}; margin-top: 15px; h4 { color: ${props => props.theme.lightWhite}; margin: 0; font-size: 12px; display: inline-block; } p { color: ${props => props.theme.contrast}; margin: 0; font-size: 14px; margin-bottom: 10px; overflow-wrap: break-word; } a { color: ${props => props.theme.contrast}; margin: 0; font-size: 10px; border: 1px solid ${props => props.theme.contrast}; border-radius: 5px; padding: 2px 10px; opacity: 0.6; } a:hover { opacity: 1; border-color: ${props => props.theme.eCashBlue}; color: ${props => props.theme.contrast}; background: ${props => props.theme.eCashBlue}; } ${({ received, ...props }) => received && ` text-align: left; background: ${props.theme.receivedMessage}; `} `; const ReceivedLabel = styled.span` font-weight: bold; color: ${props => props.theme.eCashBlue} !important; `; const EncryptionMessageLabel = styled.span` font-weight: bold; font-size: 12px; color: ${props => props.theme.encryptionRed}; white-space: nowrap; `; const UnauthorizedDecryptionMessage = styled.span` text-align: left; color: ${props => props.theme.encryptionRed}; white-space: nowrap; font-style: italic; `; const TxInfo = styled.div` text-align: right; display: flex; align-items: left; flex-direction: column; margin-left: 10px; flex-grow: 2; h3 { color: ${props => props.theme.contrast}; font-size: 14px; font-weight: 700; margin: 0; } .genesis { color: ${props => props.theme.genesisGreen}; } .received { color: ${props => props.theme.eCashBlue}; } h4 { font-size: 12px; color: ${props => props.theme.lightWhite}; margin: 0; } @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const TokenInfo = styled.div` display: flex; flex-grow: 1; justify-content: flex-end; color: ${props => props.outgoing ? props.theme.secondary : props.theme.eCashBlue}; @media screen and (max-width: 500px) { font-size: 0.8rem; grid-template-columns: 16px auto; } `; const TxTokenIcon = styled.div` img { height: 24px; width: 24px; } @media screen and (max-width: 500px) { img { height: 16px; width: 16px; } } grid-column-start: 1; grid-column-end: span 1; grid-row-start: 1; grid-row-end: span 2; align-self: center; `; const TokenTxAmt = styled.h3` text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TokenName = styled.h4` text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TxWrapper = styled.div` display: flex; align-items: center; border-top: 1px solid rgba(255, 255, 255, 0.12); color: ${props => props.theme.contrast}; padding: 10px 0; flex-wrap: wrap; width: 100%; `; const AntdContextCollapseWrapper = styled.div` .ant-collapse { border: none !important; background-color: transparent !important; } .ant-collapse-item { border: none !important; } .ant-collapse-header { padding: 0 !important; color: ${props => props.theme.forms.text} !important; } border-radius: 16px; .ant-collapse-content-box { padding-right: 0 !important; } @media screen and (max-width: 500px) { grid-template-columns: 24px 30% 50%; } `; const Panel = Collapse.Panel; const DropdownIconWrapper = styled.div` display: flex; align-items: center; gap: 4px; `; const TextLayer = styled.div` font-size: 12px; color: ${props => props.theme.contrast}; `; const DropdownButton = styled.button` display: flex; justify-content: flex-end; background-color: ${props => props.theme.walletBackground}; border: none; cursor: pointer; padding: 0; &:hover { div { color: ${props => props.theme.eCashBlue}!important; } svg { fill: ${props => props.theme.eCashBlue}!important; } } `; const PanelCtn = styled.div` display: flex; justify-content: flex-end; right: 0; gap: 8px; `; const TxLink = styled.a` color: ${props => props.theme.primary}; `; -const Tx = ({ data, fiatPrice, fiatCurrency }) => { +const NotInContactsAlert = styled.h4` + color: ${props => props.theme.forms.error} !important; + font-style: italic; +`; + +const Tx = ({ data, fiatPrice, fiatCurrency, addressesInContactList }) => { const txDate = typeof data.blocktime === 'undefined' ? formatDate() : formatDate(data.blocktime, navigator.language); // if data only includes height and txid, then the tx could not be parsed by cashtab // render as such but keep link to block explorer let unparsedTx = false; if (!Object.keys(data).includes('outgoingTx')) { unparsedTx = true; } return ( <> {unparsedTx ? ( Unparsed
{txDate}
Open in Explorer
) : ( {data.outgoingTx ? ( <> {data.tokenTx && data.tokenInfo .transactionType === 'GENESIS' ? ( ) : ( )} ) : ( )} {data.outgoingTx ? ( <> {data.tokenTx && data.tokenInfo .transactionType === 'GENESIS' ? (

Genesis

) : (

Sent

)} ) : (

Received

)}

{txDate}

{data.tokenTx ? ( {data.tokenTx && data.tokenInfo ? ( <> {data.outgoingTx ? ( {data.tokenInfo .transactionType === 'GENESIS' ? ( <> +{' '} {data.tokenInfo.qtyReceived.toString()}   { data .tokenInfo .tokenTicker } { data .tokenInfo .tokenName } ) : ( <> -{' '} {data.tokenInfo.qtySent.toString()}   { data .tokenInfo .tokenTicker } { data .tokenInfo .tokenName } )} ) : ( +{' '} {data.tokenInfo.qtyReceived.toString()}   { data .tokenInfo .tokenTicker } { data .tokenInfo .tokenName } )} ) : ( Token Tx )} ) : ( <> {data.outgoingTx ? ( <>

- {formatBalance( fromLegacyDecimals( data.amountSent, ), )}{' '} { currency.ticker }

{fiatPrice !== null && !isNaN( data.amountSent, ) && (

- { currency .fiatCurrencies[ fiatCurrency ] .symbol } {( fromLegacyDecimals( data.amountSent, ) * fiatPrice ).toFixed( 2, )}{' '} { currency .fiatCurrencies .fiatCurrency }

)} ) : ( <>

+ {formatBalance( fromLegacyDecimals( data.amountReceived, ), )}{' '} { currency.ticker }

{fiatPrice !== null && !isNaN( data.amountReceived, ) && (

+ { currency .fiatCurrencies[ fiatCurrency ] .symbol } {( fromLegacyDecimals( data.amountReceived, ) * fiatPrice ).toFixed( 2, )}{' '} { currency .fiatCurrencies .fiatCurrency }

)} )}
)} {data.opReturnMessage && ( <> + {!data.outgoingTx && + !addressesInContactList.includes( + data.replyAddress, + ) && ( + + Warning: This + sender is not in + your contact + list. Beware of + scams. + + )} {data.isCashtabMessage ? ( -

Cashtab Message

+

+ Cashtab Message{' '} +

) : (

External Message

)} {data.isEncryptedMessage ? (  - Encrypted ) : ( '' )}
{/*unencrypted OP_RETURN Message*/} {data.opReturnMessage && !data.isEncryptedMessage ? (

{ data.opReturnMessage }

) : ( '' )} {/*encrypted and wallet is authorized to view OP_RETURN Message*/} {data.opReturnMessage && data.isEncryptedMessage && data.decryptionSuccess ? (

{ data.opReturnMessage }

) : ( '' )} {/*encrypted but wallet is not authorized to view OP_RETURN Message*/} {data.opReturnMessage && data.isEncryptedMessage && !data.decryptionSuccess ? ( { data.opReturnMessage } ) : ( '' )} {!data.outgoingTx && data.replyAddress ? ( Reply To Message ) : ( '' )}
)}
} > { generalNotification( data.txid, 'Tx ID copied to clipboard', ); }} > Copy Tx ID {data.opReturnMessage && ( { generalNotification( data.opReturnMessage, 'Cashtab message copied to clipboard', ); }} > Copy Msg )} View on be.cash {!data.outgoingTx && data.replyAddress && ( Add to contacts )}
)} ); }; Tx.propTypes = { data: PropTypes.object, fiatPrice: PropTypes.number, fiatCurrency: PropTypes.string, + addressesInContactList: PropTypes.arrayOf(PropTypes.string), }; export default Tx; diff --git a/web/cashtab/src/components/Home/TxHistory.js b/web/cashtab/src/components/Home/TxHistory.js index ad30fb662..1b0e3ae7e 100644 --- a/web/cashtab/src/components/Home/TxHistory.js +++ b/web/cashtab/src/components/Home/TxHistory.js @@ -1,26 +1,36 @@ import React from 'react'; import PropTypes from 'prop-types'; import Tx from './Tx'; +import { flattenContactList } from 'utils/cashMethods'; -const TxHistory = ({ txs, fiatPrice, fiatCurrency }) => { +const TxHistory = ({ txs, fiatPrice, fiatCurrency, contactList }) => { + // Convert contactList array of objects to an array of addresses + const addressesInContactList = flattenContactList(contactList); return (
{txs.map(tx => ( ))}
); }; TxHistory.propTypes = { txs: PropTypes.array, fiatPrice: PropTypes.number, fiatCurrency: PropTypes.string, + contactList: PropTypes.arrayOf( + PropTypes.shape({ + address: PropTypes.string, + namer: PropTypes.string, + }), + ), }; export default TxHistory; diff --git a/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap b/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap index 2cab90964..5f9eb1a44 100644 --- a/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap +++ b/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap @@ -1,449 +1,449 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [

MigrationTestAlpha

0 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken

Tokens sent to your eToken address will appear here

, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [

MigrationTestAlpha

0 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken

Tokens sent to your eToken address will appear here

, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [

MigrationTestAlpha

0.06 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [

MigrationTestAlpha

0 XEC
,
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
Create eToken

Tokens sent to your eToken address will appear here

, ] `; exports[`Without wallet defined 1`] = `

Welcome to Cashtab!

Cashtab is an open source, non-custodial web wallet for eCash .

Want to learn more? Check out the Cashtab documentation.

`; diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js index fc648b898..e3d240d5f 100644 --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -1,1233 +1,1263 @@ 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, whichUtxosWereAdded, whichUtxosWereConsumed, addNewHydratedUtxos, removeConsumedUtxos, areAllUtxosIncludedInIncrementallyHydratedUtxos, } from 'utils/cashMethods'; -import { isValidCashtabSettings } from 'utils/validation'; +import { isValidCashtabSettings, isValidContactList } 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 [contactList, setContactList] = useState(false); const [cashtabSettings, setCashtabSettings] = useState(false); const [fiatPrice, setFiatPrice] = useState(null); const [apiError, setApiError] = useState(false); const [checkFiatInterval, setCheckFiatInterval] = useState(null); const [hasUpdated, setHasUpdated] = useState(false); 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 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 publicKeys = [ wallet.Path145.publicKey, wallet.Path245.publicKey, wallet.Path1899.publicKey, ]; 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; } let incrementalHydratedUtxosValid; let incrementallyAdjustedHydratedUtxoDetails; try { // Make sure you have all the required inputs to use this approach if ( !wallet || !wallet.state || !wallet.state.utxos || !wallet.state.hydratedUtxoDetails || !utxos ) { throw new Error( 'Wallet does not have required state for incremental approach, hydrating full utxo set', ); } const utxosAdded = whichUtxosWereAdded( wallet.state.utxos, utxos, ); const utxosConsumed = whichUtxosWereConsumed( wallet.state.utxos, utxos, ); incrementallyAdjustedHydratedUtxoDetails = wallet.state.hydratedUtxoDetails; if (utxosConsumed) { incrementallyAdjustedHydratedUtxoDetails = removeConsumedUtxos( utxosConsumed, incrementallyAdjustedHydratedUtxoDetails, ); } if (utxosAdded) { const addedHydratedUtxos = await getHydratedUtxoDetails( BCH, utxosAdded, ); incrementallyAdjustedHydratedUtxoDetails = addNewHydratedUtxos( addedHydratedUtxos, incrementallyAdjustedHydratedUtxoDetails, ); } incrementalHydratedUtxosValid = areAllUtxosIncludedInIncrementallyHydratedUtxos( utxos, incrementallyAdjustedHydratedUtxoDetails, ); } catch (err) { console.log( `Error in incremental determination of hydratedUtxoDetails`, ); console.log(err); incrementalHydratedUtxosValid = false; } if (!incrementalHydratedUtxosValid) { console.log( `Incremental approach invalid, hydrating all utxos`, ); incrementallyAdjustedHydratedUtxoDetails = await getHydratedUtxoDetails(BCH, utxos); } const slpBalancesAndUtxos = await getSlpBalancesAndUtxos( BCH, incrementallyAdjustedHydratedUtxoDetails, ); console.log(`slpBalancesAndUtxos`, slpBalancesAndUtxos); const txHistory = await getTxHistory(BCH, cashAddresses); // public keys are used to determined if a tx is incoming outgoing const parsedTxHistory = await getTxData( BCH, txHistory, publicKeys, wallet, ); const parsedWithTokens = await addTokenTxData(BCH, parsedTxHistory); 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 = incrementallyAdjustedHydratedUtxoDetails; // 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 updateContactListInLocalForage = async contactListArray => { let updateSuccess = true; try { await localforage.setItem('contactList', contactListArray); } catch (err) { console.log('Error in updateContactListInLocalForage', 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 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 */ setHasUpdated(false); 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. 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 (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 // 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 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; }; + 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 'settings' + 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 settings key, make sure it's valid + if (isValidContactList(localContactList)) { + setContactList(localContactList); + return localContactList; + } + // if not valid, also set to default + setContactList([{}]); + return [{}]; + }; + // 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 (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 useAsyncTimeout function, and re-sets to false during activateWallet if ( 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 if ( 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 every 10s useAsyncTimeout(async () => { const wallet = await getWallet(); update({ wallet, }).finally(() => { setLoading(false); if (!hasUpdated) { setHasUpdated(true); } }); }, 1000); 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(); const initialSettings = await loadCashtabSettings(); initializeFiatPriceApi(initialSettings.fiatCurrency); }, []); return { BCH, wallet, fiatPrice, loading, apiError, + contactList, cashtabSettings, changeCashtabSettings, getActiveWalletFromLocalForage, getWallet, validateMnemonic, getWalletDetails, getSavedWallets, migrateLegacyWallet, getContactListFromLocalForage, updateContactListInLocalForage, 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/__tests__/cashMethods.test.js b/web/cashtab/src/utils/__tests__/cashMethods.test.js index 9157d49cc..91a330577 100644 --- a/web/cashtab/src/utils/__tests__/cashMethods.test.js +++ b/web/cashtab/src/utils/__tests__/cashMethods.test.js @@ -1,823 +1,861 @@ import { ValidationError } from 'ecashaddrjs'; import { fromSmallestDenomination, batchArray, + flattenContactList, flattenBatchedHydratedUtxos, loadStoredWallet, isValidStoredWallet, fromLegacyDecimals, convertToEcashPrefix, checkNullUtxosForTokenStatus, confirmNonEtokenUtxos, isLegacyMigrationRequired, toLegacyCash, toLegacyToken, toLegacyCashArray, convertEtokenToEcashAddr, parseOpReturn, isExcludedUtxo, whichUtxosWereAdded, whichUtxosWereConsumed, addNewHydratedUtxos, removeConsumedUtxos, getUtxoCount, areAllUtxosIncludedInIncrementallyHydratedUtxos, convertEcashtoEtokenAddr, } from 'utils/cashMethods'; import { unbatchedArray, arrayBatchedByThree, } from '../__mocks__/mockBatchedArrays'; import { validAddressArrayInput, validAddressArrayInputMixedPrefixes, validAddressArrayOutput, validLargeAddressArrayInput, validLargeAddressArrayOutput, invalidAddressArrayInput, } from '../__mocks__/mockAddressArray'; 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__/mockLegacyWalletsUtils'; import { shortCashtabMessageInputHex, longCashtabMessageInputHex, shortExternalMessageInputHex, longExternalMessageInputHex, shortSegmentedExternalMessageInputHex, longSegmentedExternalMessageInputHex, mixedSegmentedExternalMessageInputHex, mockParsedShortCashtabMessageArray, mockParsedLongCashtabMessageArray, mockParsedShortExternalMessageArray, mockParsedLongExternalMessageArray, mockParsedShortSegmentedExternalMessageArray, mockParsedLongSegmentedExternalMessageArray, mockParsedMixedSegmentedExternalMessageArray, eTokenInputHex, mockParsedETokenOutputArray, } from '../__mocks__/mockOpReturnParsedArray'; import { excludedUtxoAlpha, excludedUtxoBeta, includedUtxoAlpha, includedUtxoBeta, previousUtxosObjUtxoArray, previousUtxosTemplate, currentUtxosAfterSingleXecReceiveTxTemplate, utxosAddedBySingleXecReceiveTxTemplate, previousUtxosBeforeSingleXecReceiveTx, currentUtxosAfterSingleXecReceiveTx, utxosAddedBySingleXecReceiveTx, currentUtxosAfterMultiXecReceiveTxTemplate, utxosAddedByMultiXecReceiveTxTemplate, previousUtxosBeforeMultiXecReceiveTx, currentUtxosAfterMultiXecReceiveTx, utxosAddedByMultiXecReceiveTx, currentUtxosAfterEtokenReceiveTxTemplate, utxosAddedByEtokenReceiveTxTemplate, previousUtxosBeforeEtokenReceiveTx, currentUtxosAfterEtokenReceiveTx, utxosAddedByEtokenReceiveTx, previousUtxosBeforeSendAllTxTemplate, currentUtxosAfterSendAllTxTemplate, previousUtxosBeforeSendAllTx, currentUtxosAfterSendAllTx, previousUtxosBeforeSingleXecSendTx, currentUtxosAfterSingleXecSendTx, utxosAddedBySingleXecSendTx, currentUtxosAfterSingleXecSendTxTemplate, utxosAddedBySingleXecSendTxTemplate, currentUtxosAfterEtokenSendTxTemplate, utxosAddedByEtokenSendTxTemplate, previousUtxosBeforeEtokenSendTx, currentUtxosAfterEtokenSendTx, utxosAddedByEtokenSendTx, utxosConsumedByEtokenSendTx, utxosConsumedByEtokenSendTxTemplate, utxosConsumedBySingleXecSendTx, utxosConsumedBySingleXecSendTxTemplate, utxosConsumedBySendAllTx, utxosConsumedBySendAllTxTemplate, hydratedUtxoDetailsBeforeAddingTemplate, hydratedUtxoDetailsAfterAddingSingleUtxoTemplate, newHydratedUtxosSingleTemplate, addedHydratedUtxosOverTwenty, existingHydratedUtxoDetails, existingHydratedUtxoDetailsAfterAdd, hydratedUtxoDetailsBeforeConsumedTemplate, consumedUtxoTemplate, hydratedUtxoDetailsAfterRemovingConsumedUtxoTemplate, consumedUtxos, hydratedUtxoDetailsBeforeRemovingConsumedUtxos, hydratedUtxoDetailsAfterRemovingConsumedUtxos, consumedUtxosMoreThanTwenty, hydratedUtxoDetailsAfterRemovingMoreThanTwentyConsumedUtxos, consumedUtxosMoreThanTwentyInRandomObjects, utxoCountMultiTemplate, utxoCountSingleTemplate, incrementalUtxosTemplate, incrementallyHydratedUtxosTemplate, incrementallyHydratedUtxosTemplateMissing, utxosAfterSentTxIncremental, incrementallyHydratedUtxosAfterProcessing, incrementallyHydratedUtxosAfterProcessingOneMissing, } from '../__mocks__/incrementalUtxoMocks'; 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(`toLegacyToken returns an etoken: prefix address as simpleledger:`, () => { expect( toLegacyToken('etoken:qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'), ).toBe('simpleledger:qz2708636snqhsxu8wnlka78h6fdp77ar5syue64fa'); }); it(`toLegacyToken returns an prefixless valid etoken address in simpleledger: format with prefix`, () => { expect( toLegacyToken('qz2708636snqhsxu8wnlka78h6fdp77ar5tv2tzg4r'), ).toBe('simpleledger:qz2708636snqhsxu8wnlka78h6fdp77ar5syue64fa'); }); 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); }); test('toLegacyCash() converts a valid ecash: prefix address to a valid bitcoincash: prefix address', async () => { const result = toLegacyCash( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); }); test('toLegacyCash() converts a valid ecash: prefixless address to a valid bitcoincash: prefix address', async () => { const result = toLegacyCash( 'qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); }); test('toLegacyCash throws error if input address has invalid checksum', async () => { const result = toLegacyCash( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25m', ); expect(result).toStrictEqual( new Error( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25m is not a valid ecash address', ), ); }); test('toLegacyCash() throws error with input of etoken: address', async () => { const result = toLegacyCash( 'etoken:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4g9htlunl0', ); expect(result).toStrictEqual( new Error( 'etoken:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4g9htlunl0 is not a valid ecash address', ), ); }); test('toLegacyCash() throws error with input of legacy address', async () => { const result = toLegacyCash('13U6nDrkRsC3Eb1pxPhNY8XJ5W9zdp6rNk'); expect(result).toStrictEqual( new Error( '13U6nDrkRsC3Eb1pxPhNY8XJ5W9zdp6rNk is not a valid ecash address', ), ); }); test('toLegacyCash() throws error with input of bitcoincash: address', async () => { const result = toLegacyCash( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); expect(result).toStrictEqual( new Error( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0 is not a valid ecash address', ), ); }); test('toLegacyCashArray throws error if the addressArray input is null', async () => { const result = toLegacyCashArray(null); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyCashArray throws error if the addressArray input is empty', async () => { const result = toLegacyCashArray([]); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyCashArray throws error if the addressArray input is a number', async () => { const result = toLegacyCashArray(12345); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyCashArray throws error if the addressArray input is undefined', async () => { const result = toLegacyCashArray(undefined); expect(result).toStrictEqual(new Error('Invalid addressArray input')); }); test('toLegacyCashArray successfully converts a standard sized valid addressArray input', async () => { const result = toLegacyCashArray(validAddressArrayInput); expect(result).toStrictEqual(validAddressArrayOutput); }); test('toLegacyCashArray successfully converts a standard sized valid addressArray input including prefixless ecash addresses', async () => { const result = toLegacyCashArray(validAddressArrayInputMixedPrefixes); expect(result).toStrictEqual(validAddressArrayOutput); }); test('toLegacyCashArray successfully converts a large valid addressArray input', async () => { const result = toLegacyCashArray(validLargeAddressArrayInput); expect(result).toStrictEqual(validLargeAddressArrayOutput); }); test('toLegacyCashArray throws an error on an addressArray with invalid addresses', async () => { const result = toLegacyCashArray(invalidAddressArrayInput); expect(result).toStrictEqual( new Error( 'ecash:qrqgwxrrrrrrrrrrrrrrrrrrrrrrrrr7zsvk is not a valid ecash address', ), ); }); test('parseOpReturn() successfully parses a short cashtab message', async () => { const result = parseOpReturn(shortCashtabMessageInputHex); expect(result).toStrictEqual(mockParsedShortCashtabMessageArray); }); test('parseOpReturn() successfully parses a long cashtab message where an additional PUSHDATA1 is present', async () => { const result = parseOpReturn(longCashtabMessageInputHex); expect(result).toStrictEqual(mockParsedLongCashtabMessageArray); }); test('parseOpReturn() successfully parses a short external message', async () => { const result = parseOpReturn(shortExternalMessageInputHex); expect(result).toStrictEqual(mockParsedShortExternalMessageArray); }); test('parseOpReturn() successfully parses a long external message where an additional PUSHDATA1 is present', async () => { const result = parseOpReturn(longExternalMessageInputHex); expect(result).toStrictEqual(mockParsedLongExternalMessageArray); }); test('parseOpReturn() successfully parses an external message that is segmented into separate short parts', async () => { const result = parseOpReturn(shortSegmentedExternalMessageInputHex); expect(result).toStrictEqual( mockParsedShortSegmentedExternalMessageArray, ); }); test('parseOpReturn() successfully parses an external message that is segmented into separate long parts', async () => { const result = parseOpReturn(longSegmentedExternalMessageInputHex); expect(result).toStrictEqual( mockParsedLongSegmentedExternalMessageArray, ); }); test('parseOpReturn() successfully parses an external message that is segmented into separate long and short parts', async () => { const result = parseOpReturn(mixedSegmentedExternalMessageInputHex); expect(result).toStrictEqual( mockParsedMixedSegmentedExternalMessageArray, ); }); test('parseOpReturn() successfully parses an eToken output', async () => { const result = parseOpReturn(eTokenInputHex); expect(result).toStrictEqual(mockParsedETokenOutputArray); }); test('isExcludedUtxo returns true for a utxo with different tx_pos and same txid as an existing utxo in the set', async () => { expect( isExcludedUtxo(excludedUtxoAlpha, previousUtxosObjUtxoArray), ).toBe(true); }); test('isExcludedUtxo returns true for a utxo with different value and same txid as an existing utxo in the set', async () => { expect( isExcludedUtxo(excludedUtxoBeta, previousUtxosObjUtxoArray), ).toBe(true); }); test('isExcludedUtxo returns false for a utxo with different tx_pos and same txid', async () => { expect( isExcludedUtxo(includedUtxoAlpha, previousUtxosObjUtxoArray), ).toBe(false); }); test('isExcludedUtxo returns false for a utxo with different value and same txid', async () => { expect( isExcludedUtxo(includedUtxoBeta, previousUtxosObjUtxoArray), ).toBe(false); }); test('whichUtxosWereAdded correctly identifies a single added utxo after a received XEC tx [template]', async () => { expect( whichUtxosWereAdded( previousUtxosTemplate, currentUtxosAfterSingleXecReceiveTxTemplate, ), ).toStrictEqual(utxosAddedBySingleXecReceiveTxTemplate); }); test('whichUtxosWereAdded correctly identifies a single added utxo after a received XEC tx', async () => { expect( whichUtxosWereAdded( previousUtxosBeforeSingleXecReceiveTx, currentUtxosAfterSingleXecReceiveTx, ), ).toStrictEqual(utxosAddedBySingleXecReceiveTx); }); test('whichUtxosWereAdded correctly identifies multiple added utxos with the same txid [template]', async () => { expect( whichUtxosWereAdded( previousUtxosTemplate, currentUtxosAfterMultiXecReceiveTxTemplate, ), ).toStrictEqual(utxosAddedByMultiXecReceiveTxTemplate); }); test('whichUtxosWereAdded correctly identifies multiple added utxos with the same txid', async () => { expect( whichUtxosWereAdded( previousUtxosBeforeMultiXecReceiveTx, currentUtxosAfterMultiXecReceiveTx, ), ).toStrictEqual(utxosAddedByMultiXecReceiveTx); }); test('whichUtxosWereAdded correctly identifies an added utxos from received eToken tx [template]', async () => { expect( whichUtxosWereAdded( previousUtxosTemplate, currentUtxosAfterEtokenReceiveTxTemplate, ), ).toStrictEqual(utxosAddedByEtokenReceiveTxTemplate); }); test('whichUtxosWereAdded correctly identifies an added utxos from received eToken tx', async () => { expect( whichUtxosWereAdded( previousUtxosBeforeEtokenReceiveTx, currentUtxosAfterEtokenReceiveTx, ), ).toStrictEqual(utxosAddedByEtokenReceiveTx); }); test('whichUtxosWereAdded correctly identifies no utxos were added in a send all XEC tx (no change) [template]', async () => { expect( whichUtxosWereAdded( previousUtxosBeforeSendAllTxTemplate, currentUtxosAfterSendAllTxTemplate, ), ).toStrictEqual(false); }); test('whichUtxosWereAdded correctly identifies no utxos were added in a send all XEC tx (no change)', async () => { expect( whichUtxosWereAdded( previousUtxosBeforeSendAllTx, currentUtxosAfterSendAllTx, ), ).toStrictEqual(false); }); test('whichUtxosWereAdded correctly identifies an added utxo from a single send XEC tx', async () => { expect( whichUtxosWereAdded( previousUtxosBeforeSingleXecSendTx, currentUtxosAfterSingleXecSendTx, ), ).toStrictEqual(utxosAddedBySingleXecSendTx); }); test('whichUtxosWereAdded correctly identifies an added utxo from a single send XEC tx [template]', async () => { expect( whichUtxosWereAdded( previousUtxosTemplate, currentUtxosAfterSingleXecSendTxTemplate, ), ).toStrictEqual(utxosAddedBySingleXecSendTxTemplate); }); test('whichUtxosWereAdded correctly identifies added change utxos from a send eToken tx [template]', async () => { expect( whichUtxosWereAdded( previousUtxosTemplate, currentUtxosAfterEtokenSendTxTemplate, ), ).toStrictEqual(utxosAddedByEtokenSendTxTemplate); }); test('whichUtxosWereAdded correctly identifies added change utxos from a send eToken tx', async () => { expect( whichUtxosWereAdded( previousUtxosBeforeEtokenSendTx, currentUtxosAfterEtokenSendTx, ), ).toStrictEqual(utxosAddedByEtokenSendTx); }); test('whichUtxosWereConsumed correctly identifies no utxos consumed after a received XEC tx [template]', async () => { expect( whichUtxosWereConsumed( previousUtxosTemplate, currentUtxosAfterSingleXecReceiveTxTemplate, ), ).toStrictEqual(false); }); test('whichUtxosWereConsumed correctly identifies no utxos consumed a received XEC tx', async () => { expect( whichUtxosWereConsumed( previousUtxosBeforeSingleXecReceiveTx, currentUtxosAfterSingleXecReceiveTx, ), ).toStrictEqual(false); }); test('whichUtxosWereConsumed correctly identifies no consumed utxos after receiving an XEC multi-send tx [template]', async () => { expect( whichUtxosWereConsumed( previousUtxosTemplate, currentUtxosAfterMultiXecReceiveTxTemplate, ), ).toStrictEqual(false); }); test('whichUtxosWereConsumed correctly identifies no consumed utxos after receiving an XEC multi-send tx', async () => { expect( whichUtxosWereConsumed( previousUtxosBeforeMultiXecReceiveTx, currentUtxosAfterMultiXecReceiveTx, ), ).toStrictEqual(false); }); test('whichUtxosWereConsumed correctly identifies consumed utxos from a single send XEC tx', async () => { expect( whichUtxosWereConsumed( previousUtxosBeforeSingleXecSendTx, currentUtxosAfterSingleXecSendTx, ), ).toStrictEqual(utxosConsumedBySingleXecSendTx); }); test('whichUtxosWereConsumed correctly identifies consumed utxos from a send all XEC tx [template]', async () => { expect( whichUtxosWereConsumed( previousUtxosBeforeSendAllTxTemplate, currentUtxosAfterSendAllTxTemplate, ), ).toStrictEqual(utxosConsumedBySendAllTxTemplate); }); test('whichUtxosWereConsumed correctly identifies consumed utxos from a send all XEC tx', async () => { expect( whichUtxosWereConsumed( previousUtxosBeforeSendAllTx, currentUtxosAfterSendAllTx, ), ).toStrictEqual(utxosConsumedBySendAllTx); }); test('whichUtxosWereConsumed correctly identifies consumed utxos from a single send XEC tx [template]', async () => { expect( whichUtxosWereConsumed( previousUtxosTemplate, currentUtxosAfterSingleXecSendTxTemplate, ), ).toStrictEqual(utxosConsumedBySingleXecSendTxTemplate); }); test('whichUtxosWereConsumed correctly identifies consumed utxos from a send eToken tx [template]', async () => { expect( whichUtxosWereConsumed( previousUtxosTemplate, currentUtxosAfterEtokenSendTxTemplate, ), ).toStrictEqual(utxosConsumedByEtokenSendTxTemplate); }); test('whichUtxosWereConsumed correctly identifies consumed utxos from a send eToken tx', async () => { expect( whichUtxosWereConsumed( previousUtxosBeforeEtokenSendTx, currentUtxosAfterEtokenSendTx, ), ).toStrictEqual(utxosConsumedByEtokenSendTx); }); test('addNewHydratedUtxos correctly adds new utxos object to existing hydratedUtxoDetails object', async () => { expect( addNewHydratedUtxos( newHydratedUtxosSingleTemplate, hydratedUtxoDetailsBeforeAddingTemplate, ), ).toStrictEqual(hydratedUtxoDetailsAfterAddingSingleUtxoTemplate); }); test('addNewHydratedUtxos correctly adds more than 20 new hydrated utxos to existing hydratedUtxoDetails object', async () => { expect( addNewHydratedUtxos( addedHydratedUtxosOverTwenty, existingHydratedUtxoDetails, ), ).toStrictEqual(existingHydratedUtxoDetailsAfterAdd); }); test('removeConsumedUtxos correctly removes a single utxo from hydratedUtxoDetails - template', async () => { expect( removeConsumedUtxos( consumedUtxoTemplate, hydratedUtxoDetailsBeforeConsumedTemplate, ), ).toStrictEqual(hydratedUtxoDetailsAfterRemovingConsumedUtxoTemplate); }); test('removeConsumedUtxos correctly removes a single utxo from hydratedUtxoDetails', async () => { expect( removeConsumedUtxos( consumedUtxos, hydratedUtxoDetailsBeforeRemovingConsumedUtxos, ), ).toStrictEqual(hydratedUtxoDetailsAfterRemovingConsumedUtxos); }); test('removeConsumedUtxos correctly removes more than twenty utxos from hydratedUtxoDetails', async () => { expect( removeConsumedUtxos( consumedUtxosMoreThanTwenty, hydratedUtxoDetailsBeforeRemovingConsumedUtxos, ), ).toStrictEqual( hydratedUtxoDetailsAfterRemovingMoreThanTwentyConsumedUtxos, ); }); test('removeConsumedUtxos correctly removes more than twenty utxos from multiple utxo objects from hydratedUtxoDetails', async () => { expect( removeConsumedUtxos( consumedUtxosMoreThanTwentyInRandomObjects, hydratedUtxoDetailsBeforeRemovingConsumedUtxos, ), ).toStrictEqual( hydratedUtxoDetailsAfterRemovingMoreThanTwentyConsumedUtxos, ); }); test('getUtxoCount correctly calculates the total for a utxo object with empty addresses [template]', async () => { expect(getUtxoCount(utxoCountSingleTemplate)).toStrictEqual(1); }); test('getUtxoCount correctly calculates the total for multiple utxos [template]', async () => { expect(getUtxoCount(utxoCountMultiTemplate)).toStrictEqual(12); }); test('areAllUtxosIncludedInIncrementallyHydratedUtxos correctly identifies all utxos are in incrementally hydrated utxos [template]', async () => { expect( areAllUtxosIncludedInIncrementallyHydratedUtxos( incrementalUtxosTemplate, incrementallyHydratedUtxosTemplate, ), ).toBe(true); }); test('areAllUtxosIncludedInIncrementallyHydratedUtxos returns false if a utxo in the utxo set is not in incrementally hydrated utxos [template]', async () => { expect( areAllUtxosIncludedInIncrementallyHydratedUtxos( incrementalUtxosTemplate, incrementallyHydratedUtxosTemplateMissing, ), ).toBe(false); }); test('areAllUtxosIncludedInIncrementallyHydratedUtxos correctly identifies all utxos are in incrementally hydrated utxos', async () => { expect( areAllUtxosIncludedInIncrementallyHydratedUtxos( utxosAfterSentTxIncremental, incrementallyHydratedUtxosAfterProcessing, ), ).toBe(true); }); test('areAllUtxosIncludedInIncrementallyHydratedUtxos returns false if a utxo in the utxo set is not in incrementally hydrated utxos', async () => { expect( areAllUtxosIncludedInIncrementallyHydratedUtxos( utxosAfterSentTxIncremental, incrementallyHydratedUtxosAfterProcessingOneMissing, ), ).toBe(false); }); test('areAllUtxosIncludedInIncrementallyHydratedUtxos returns false if utxo set is invalid', async () => { expect( areAllUtxosIncludedInIncrementallyHydratedUtxos( {}, incrementallyHydratedUtxosAfterProcessing, ), ).toBe(false); }); test('convertEtokenToEcashAddr successfully converts a valid eToken address to eCash', async () => { const result = convertEtokenToEcashAddr( 'etoken:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gcldpffcs', ); expect(result).toStrictEqual( 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8', ); }); test('convertEtokenToEcashAddr successfully converts prefixless eToken address as input', async () => { const result = convertEtokenToEcashAddr( 'qpatql05s9jfavnu0tv6lkjjk25n6tmj9gcldpffcs', ); expect(result).toStrictEqual( 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8', ); }); test('convertEtokenToEcashAddr throws error with an invalid eToken address as input', async () => { const result = convertEtokenToEcashAddr('etoken:qpj9gcldpffcs'); expect(result).toStrictEqual( new Error( 'cashMethods.convertToEcashAddr() error: etoken:qpj9gcldpffcs is not a valid etoken address', ), ); }); test('convertEtokenToEcashAddr throws error with an ecash address as input', async () => { const result = convertEtokenToEcashAddr( 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8', ); expect(result).toStrictEqual( new Error( 'cashMethods.convertToEcashAddr() error: ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8 is not a valid etoken address', ), ); }); test('convertEtokenToEcashAddr throws error with null input', async () => { const result = convertEtokenToEcashAddr(null); expect(result).toStrictEqual( new Error( 'cashMethods.convertToEcashAddr() error: No etoken address provided', ), ); }); test('convertEtokenToEcashAddr throws error with empty string input', async () => { const result = convertEtokenToEcashAddr(''); expect(result).toStrictEqual( new Error( 'cashMethods.convertToEcashAddr() error: No etoken address provided', ), ); }); test('convertEcashtoEtokenAddr successfully converts a valid ecash address into an etoken address', async () => { const eCashAddress = 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8'; const eTokenAddress = 'etoken:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gcldpffcs'; const result = convertEcashtoEtokenAddr(eCashAddress); expect(result).toStrictEqual(eTokenAddress); }); test('convertEcashtoEtokenAddr successfully converts a valid prefix-less ecash address into an etoken address', async () => { const eCashAddress = 'qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8'; const eTokenAddress = 'etoken:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gcldpffcs'; const result = convertEcashtoEtokenAddr(eCashAddress); expect(result).toStrictEqual(eTokenAddress); }); test('convertEcashtoEtokenAddr throws error with invalid ecash address input', async () => { const eCashAddress = 'ecash:qpaNOTVALIDADDRESSwu8'; const result = convertEcashtoEtokenAddr(eCashAddress); expect(result).toStrictEqual( new Error(eCashAddress + ' is not a valid ecash address'), ); }); test('convertEcashtoEtokenAddr throws error with a valid etoken address input', async () => { const eTokenAddress = 'etoken:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gcldpffcs'; const result = convertEcashtoEtokenAddr(eTokenAddress); expect(result).toStrictEqual( new Error(eTokenAddress + ' is not a valid ecash address'), ); }); test('convertEcashtoEtokenAddr throws error with a valid bitcoincash address input', async () => { const bchAddress = 'bitcoincash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9g0vsgy56s'; const result = convertEcashtoEtokenAddr(bchAddress); expect(result).toStrictEqual( new Error(bchAddress + ' is not a valid ecash address'), ); }); test('convertEcashtoEtokenPrefix throws error with null ecash address input', async () => { const eCashAddress = null; const result = convertEcashtoEtokenAddr(eCashAddress); expect(result).toStrictEqual( new Error(eCashAddress + ' is not a valid ecash address'), ); }); + + it(`flattenContactList flattens contactList array by returning an array of addresses`, () => { + expect( + flattenContactList([ + { + address: 'ecash:qpdkc5p7f25hwkxsr69m3evlj4h7wqq9xcgmjc8sxr', + name: 'Alpha', + }, + { + address: 'ecash:qpq235n3l3u6ampc8slapapnatwfy446auuv64ylt2', + name: 'Beta', + }, + { + address: 'ecash:qz50e58nkeg2ej2f34z6mhwylp6ven8emy8pp52r82', + name: 'Gamma', + }, + ]), + ).toStrictEqual([ + 'ecash:qpdkc5p7f25hwkxsr69m3evlj4h7wqq9xcgmjc8sxr', + 'ecash:qpq235n3l3u6ampc8slapapnatwfy446auuv64ylt2', + 'ecash:qz50e58nkeg2ej2f34z6mhwylp6ven8emy8pp52r82', + ]); + }); + + it(`flattenContactList flattens contactList array of length 1 by returning an array of 1 address`, () => { + expect( + flattenContactList([ + { + address: 'ecash:qpdkc5p7f25hwkxsr69m3evlj4h7wqq9xcgmjc8sxr', + name: 'Alpha', + }, + ]), + ).toStrictEqual(['ecash:qpdkc5p7f25hwkxsr69m3evlj4h7wqq9xcgmjc8sxr']); + }); + it(`flattenContactList returns an empty array for invalid input`, () => { + expect(flattenContactList(false)).toStrictEqual([]); + }); }); diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js index 5ca25a339..f46a5fe1e 100644 --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -1,616 +1,665 @@ import { shouldRejectAmountInput, fiatToCrypto, isValidTokenName, isValidTokenTicker, isValidTokenDecimals, isValidTokenInitialQty, isValidTokenDocumentUrl, isValidTokenStats, isValidCashtabSettings, isValidXecAddress, isValidEtokenAddress, isValidXecSendAmount, isValidSendToMany, isValidUtxo, isValidBchApiUtxoObject, isValidEtokenBurnAmount, isValidTokenId, isValidXecAirdrop, isValidAirdropOutputsArray, isValidAirdropExclusionArray, + isValidContactList, } from '../validation'; import { currency } from 'components/Common/Ticker.js'; import { fromSmallestDenomination } from 'utils/cashMethods'; import { stStatsValid, noCovidStatsValid, noCovidStatsInvalid, cGenStatsValid, } from '../__mocks__/mockTokenStats'; import { validXecAirdropList, invalidXecAirdropList, invalidXecAirdropListMultipleInvalidValues, invalidXecAirdropListMultipleValidValues, } from '../__mocks__/mockXecAirdropRecipients'; import { validUtxo, invalidUtxoMissingHeight, invalidUtxoTxidUndefined, hydratedUtxoDetailsAfterRemovingConsumedUtxos, utxosAfterSentTxIncremental, } from '../__mocks__/incrementalUtxoMocks'; import { validXecAirdropExclusionList, invalidXecAirdropExclusionList, } from '../__mocks__/mockXecAirdropExclusionList'; describe('Validation utils', () => { 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 ${fromSmallestDenomination( currency.dustSats, ).toString()} minimum`, () => { const expectedValidationError = `Send amount must be at least ${fromSmallestDenomination( currency.dustSats, ).toString()} ${currency.ticker}`; expect( shouldRejectAmountInput( ( fromSmallestDenomination(currency.dustSats).toString() - 0.00000001 ).toString(), currency.ticker, 20.0, 3, ), ).toBe(expectedValidationError); }); it(`Returns error if ${ currency.ticker } send amount is less than ${fromSmallestDenomination( currency.dustSats, ).toString()} minimum in fiat currency`, () => { const expectedValidationError = `Send amount must be at least ${fromSmallestDenomination( 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('DOGE')).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(`Correctly validates token stats for token created before the ${currency.ticker} fork`, () => { expect(isValidTokenStats(stStatsValid)).toBe(true); }); it(`Correctly validates token stats for token created after the ${currency.ticker} fork`, () => { expect(isValidTokenStats(noCovidStatsValid)).toBe(true); }); it(`Correctly validates token stats for token with no minting baton`, () => { expect(isValidTokenStats(cGenStatsValid)).toBe(true); }); it(`Recognizes a token stats object with missing required keys as invalid`, () => { expect(isValidTokenStats(noCovidStatsInvalid)).toBe(false); }); it(`Recognizes a valid cashtab settings object`, () => { expect( isValidCashtabSettings({ fiatCurrency: 'usd', sendModal: 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(`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 = fromSmallestDenomination(currency.dustSats); expect(isValidXecSendAmount(testXecSendAmount)).toBe(true); }); it(`isValidXecSendAmount accepts arbitrary number above dust minimum`, () => { const testXecSendAmount = fromSmallestDenomination(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 = `${ fromSmallestDenomination(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(`isValidUtxo returns true for a valid utxo`, () => { expect(isValidUtxo(validUtxo)).toBe(true); }); it(`isValidUtxo returns false for missing height`, () => { expect(isValidUtxo(invalidUtxoMissingHeight)).toBe(false); }); it(`isValidUtxo returns false for undefined tx_hash`, () => { expect(isValidUtxo(invalidUtxoTxidUndefined)).toBe(false); }); it(`isValidUtxo returns false for null`, () => { expect(isValidUtxo(null)).toBe(false); }); it(`isValidUtxo returns false for undefined`, () => { expect(isValidUtxo()).toBe(false); }); it(`isValidUtxo returns false for empty object`, () => { expect(isValidUtxo({})).toBe(false); }); it(`isValidBchApiUtxoObject returns false for object`, () => { expect(isValidBchApiUtxoObject({})).toBe(false); }); it(`isValidBchApiUtxoObject returns false for empty array`, () => { expect(isValidBchApiUtxoObject([])).toBe(false); }); it(`isValidBchApiUtxoObject returns false for null`, () => { expect(isValidBchApiUtxoObject(null)).toBe(false); }); it(`isValidBchApiUtxoObject returns false for undefined`, () => { expect(isValidBchApiUtxoObject(undefined)).toBe(false); }); it(`isValidBchApiUtxoObject returns false for hydratedUtxoDetails type object`, () => { expect( isValidBchApiUtxoObject( hydratedUtxoDetailsAfterRemovingConsumedUtxos, ), ).toBe(false); }); it(`isValidBchApiUtxoObject returns true for hydratedUtxoDetails.slpUtxos`, () => { expect( isValidBchApiUtxoObject( hydratedUtxoDetailsAfterRemovingConsumedUtxos.slpUtxos, ), ).toBe(true); }); it(`isValidBchApiUtxoObject returns true for valid bch-api utxos object`, () => { expect(isValidBchApiUtxoObject(utxosAfterSentTxIncremental)).toBe(true); }); 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)); }); diff --git a/web/cashtab/src/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js index 2d9ad6d31..cf4631fb5 100644 --- a/web/cashtab/src/utils/cashMethods.js +++ b/web/cashtab/src/utils/cashMethods.js @@ -1,953 +1,971 @@ import { currency } from 'components/Common/Ticker'; import { isValidXecAddress, isValidEtokenAddress, isValidBchApiUtxoObject, + isValidContactList, } from 'utils/validation'; import BigNumber from 'bignumber.js'; import cashaddr from 'ecashaddrjs'; export function parseOpReturn(hexStr) { if ( !hexStr || typeof hexStr !== 'string' || hexStr.substring(0, 2) !== currency.opReturn.opReturnPrefixHex ) { return false; } hexStr = hexStr.slice(2); // remove the first byte i.e. 6a /* * @Return: resultArray is structured as follows: * resultArray[0] is the transaction type i.e. eToken prefix, cashtab prefix, external message itself if unrecognized prefix * resultArray[1] is the actual cashtab message or the 2nd part of an external message * resultArray[2 - n] are the additional messages for future protcols */ let resultArray = []; let message = ''; let hexStrLength = hexStr.length; for (let i = 0; hexStrLength !== 0; i++) { // part 1: check the preceding byte value for the subsequent message let byteValue = hexStr.substring(0, 2); let msgByteSize = 0; if (byteValue === currency.opReturn.opPushDataOne) { // if this byte is 4c then the next byte is the message byte size - retrieve the message byte size only msgByteSize = parseInt(hexStr.substring(2, 4), 16); // hex base 16 to decimal base 10 hexStr = hexStr.slice(4); // strip the 4c + message byte size info } else { // take the byte as the message byte size msgByteSize = parseInt(hexStr.substring(0, 2), 16); // hex base 16 to decimal base 10 hexStr = hexStr.slice(2); // strip the message byte size info } // part 2: parse the subsequent message based on bytesize const msgCharLength = 2 * msgByteSize; message = hexStr.substring(0, msgCharLength); if (i === 0 && message === currency.opReturn.appPrefixesHex.eToken) { // add the extracted eToken prefix to array then exit loop resultArray[i] = currency.opReturn.appPrefixesHex.eToken; break; } else if ( i === 0 && message === currency.opReturn.appPrefixesHex.cashtab ) { // add the extracted Cashtab prefix to array resultArray[i] = currency.opReturn.appPrefixesHex.cashtab; } else if ( i === 0 && message === currency.opReturn.appPrefixesHex.cashtabEncrypted ) { // add the Cashtab encryption prefix to array resultArray[i] = currency.opReturn.appPrefixesHex.cashtabEncrypted; } else { // this is either an external message or a subsequent cashtab message loop to extract the message resultArray[i] = message; } // strip out the parsed message hexStr = hexStr.slice(msgCharLength); hexStrLength = hexStr.length; } return resultArray; } 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 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 flattenContactList = contactList => { + /* + Converts contactList from array of objects of type {address: , name: } to array of addresses only + + If contact list is invalid, returns and empty array + */ + if (!isValidContactList(contactList)) { + return []; + } + let flattenedContactList = []; + for (let i = 0; i < contactList.length; i += 1) { + const thisAddress = contactList[i].address; + flattenedContactList.push(thisAddress); + } + return flattenedContactList; +}; + 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 convertEtokenToEcashAddr(eTokenAddress) { if (!eTokenAddress) { return new Error( `cashMethods.convertToEcashAddr() error: No etoken address provided`, ); } // Confirm input is a valid eToken address const isValidInput = isValidEtokenAddress(eTokenAddress); if (!isValidInput) { return new Error( `cashMethods.convertToEcashAddr() error: ${eTokenAddress} is not a valid etoken address`, ); } // Check for etoken: prefix const isPrefixedEtokenAddress = eTokenAddress.slice(0, 7) === 'etoken:'; // If no prefix, assume it is checksummed for an etoken: prefix const testedEtokenAddr = isPrefixedEtokenAddress ? eTokenAddress : `etoken:${eTokenAddress}`; let ecashAddress; try { const { type, hash } = cashaddr.decode(testedEtokenAddr); ecashAddress = cashaddr.encode('ecash', type, hash); } catch (err) { return err; } return ecashAddress; } 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 convertEcashtoEtokenAddr(eCashAddress) { const isValidInput = isValidXecAddress(eCashAddress); if (!isValidInput) { return new Error(`${eCashAddress} is not a valid ecash address`); } // Check for ecash: prefix const isPrefixedEcashAddress = eCashAddress.slice(0, 6) === 'ecash:'; // If no prefix, assume it is checksummed for an ecash: prefix const testedEcashAddr = isPrefixedEcashAddress ? eCashAddress : `ecash:${eCashAddress}`; let eTokenAddress; try { const { type, hash } = cashaddr.decode(testedEcashAddr); eTokenAddress = cashaddr.encode('etoken', type, hash); } catch (err) { return new Error('eCash to eToken address conversion error'); } return eTokenAddress; } export function toLegacyCash(addr) { // Confirm input is a valid ecash address const isValidInput = isValidXecAddress(addr); if (!isValidInput) { return new Error(`${addr} is not a valid ecash address`); } // Check for ecash: prefix const isPrefixedXecAddress = addr.slice(0, 6) === 'ecash:'; // If no prefix, assume it is checksummed for an ecash: prefix const testedXecAddr = isPrefixedXecAddress ? addr : `ecash:${addr}`; let legacyCashAddress; try { const { type, hash } = cashaddr.decode(testedXecAddr); legacyCashAddress = cashaddr.encode(currency.legacyPrefix, type, hash); } catch (err) { return err; } return legacyCashAddress; } export function toLegacyCashArray(addressArray) { let cleanArray = []; // array of bch converted addresses to be returned if ( addressArray === null || addressArray === undefined || !addressArray.length || addressArray === '' ) { return new Error('Invalid addressArray input'); } const arrayLength = addressArray.length; for (let i = 0; i < arrayLength; i++) { let addressValueArr = addressArray[i].split(','); let address = addressValueArr[0]; let value = addressValueArr[1]; // NB that toLegacyCash() includes address validation; will throw error for invalid address input const legacyAddress = toLegacyCash(address); if (legacyAddress instanceof Error) { return legacyAddress; } let convertedArrayData = legacyAddress + ',' + value + '\n'; cleanArray.push(convertedArrayData); } return cleanArray; } export function toLegacyToken(addr) { // Confirm input is a valid ecash address const isValidInput = isValidEtokenAddress(addr); if (!isValidInput) { return new Error(`${addr} is not a valid etoken address`); } // Check for ecash: prefix const isPrefixedEtokenAddress = addr.slice(0, 7) === 'etoken:'; // If no prefix, assume it is checksummed for an ecash: prefix const testedEtokenAddr = isPrefixedEtokenAddress ? addr : `etoken:${addr}`; let legacyTokenAddress; try { const { type, hash } = cashaddr.decode(testedEtokenAddr); legacyTokenAddress = cashaddr.encode('simpleledger', type, hash); } catch (err) { return err; } return legacyTokenAddress; } 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; }; /* Converts a serialized buffer containing encrypted data into an object * that can be interpreted by the ecies-lite library. * * For reference on the parsing logic in this function refer to the link below on the segment of * ecies-lite's encryption function where the encKey, macKey, iv and cipher are sliced and concatenated * https://github.com/tibetty/ecies-lite/blob/8fd97e80b443422269d0223ead55802378521679/index.js#L46-L55 * * A similar PSF implmentation can also be found at: * https://github.com/Permissionless-Software-Foundation/bch-encrypt-lib/blob/master/lib/encryption.js * * For more detailed overview on the ecies encryption scheme, see https://cryptobook.nakov.com/asymmetric-key-ciphers/ecies-public-key-encryption */ export const convertToEncryptStruct = encryptionBuffer => { // based on ecies-lite's encryption logic, the encryption buffer is concatenated as follows: // [ epk + iv + ct + mac ] whereby: // - The first 32 or 64 chars of the encryptionBuffer is the epk // - Both iv and ct params are 16 chars each, hence their combined substring is 32 chars from the end of the epk string // - within this combined iv/ct substring, the first 16 chars is the iv param, and ct param being the later half // - The mac param is appended to the end of the encryption buffer // validate input buffer if (!encryptionBuffer) { throw new Error( 'cashmethods.convertToEncryptStruct() error: input must be a buffer', ); } try { // variable tracking the starting char position for string extraction purposes let startOfBuf = 0; // *** epk param extraction *** // The first char of the encryptionBuffer indicates the type of the public key // If the first char is 4, then the public key is 64 chars // If the first char is 3 or 2, then the public key is 32 chars // Otherwise this is not a valid encryption buffer compatible with the ecies-lite library let publicKey; switch (encryptionBuffer[0]) { case 4: publicKey = encryptionBuffer.slice(0, 65); // extract first 64 chars as public key break; case 3: case 2: publicKey = encryptionBuffer.slice(0, 33); // extract first 32 chars as public key break; default: throw new Error(`Invalid type: ${encryptionBuffer[0]}`); } // *** iv and ct param extraction *** startOfBuf += publicKey.length; // sets the starting char position to the end of the public key (epk) in order to extract subsequent iv and ct substrings const encryptionTagLength = 32; // the length of the encryption tag (i.e. mac param) computed from each block of ciphertext, and is used to verify no one has tampered with the encrypted data const ivCtSubstring = encryptionBuffer.slice( startOfBuf, encryptionBuffer.length - encryptionTagLength, ); // extract the substring containing both iv and ct params, which is after the public key but before the mac param i.e. the 'encryption tag' const ivbufParam = ivCtSubstring.slice(0, 16); // extract the first 16 chars of substring as the iv param const ctbufParam = ivCtSubstring.slice(16); // extract the last 16 chars as substring the ct param // *** mac param extraction *** const macParam = encryptionBuffer.slice( encryptionBuffer.length - encryptionTagLength, encryptionBuffer.length, ); // extract the mac param appended to the end of the buffer return { iv: ivbufParam, epk: publicKey, ct: ctbufParam, mac: macParam, }; } catch (err) { console.error(`useBCH.convertToEncryptStruct() error: `, err); throw err; } }; export const getPublicKey = async (BCH, address) => { try { const publicKey = await BCH.encryption.getPubKey(address); return publicKey.publicKey; } catch (err) { if (err['error'] === 'No transaction history.') { throw new Error( 'Cannot send an encrypted message to a wallet with no outgoing transactions', ); } else { throw err; } } }; 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; }; export const isExcludedUtxo = (utxo, utxoArray) => { /* utxo is a single utxo of model { height: 724992 tx_hash: "8d4bdedb7c4443412e0c2f316a330863aef54d9ba73560ca60cca6408527b247" tx_pos: 0 value: 10200 } utxoArray is an array of utxos */ let isExcludedUtxo = true; const { tx_hash, tx_pos, value } = utxo; for (let i = 0; i < utxoArray.length; i += 1) { const thisUtxo = utxoArray[i]; // NOTE // You can't match height, as this changes from 0 to blockheight after confirmation //const thisUtxoHeight = thisUtxo.height; const thisUtxoTxid = thisUtxo.tx_hash; const thisUtxoTxPos = thisUtxo.tx_pos; const thisUtxoValue = thisUtxo.value; // If you find a utxo such that each object key is identical if ( tx_hash === thisUtxoTxid && tx_pos === thisUtxoTxPos && value === thisUtxoValue ) { // Then this utxo is not excluded from the array isExcludedUtxo = false; } } return isExcludedUtxo; }; export const whichUtxosWereAdded = (previousUtxos, currentUtxos) => { let utxosAddedFlag = false; const utxosAdded = []; // Iterate over currentUtxos // For each currentUtxo -- does it exist in previousUtxos? // If no, it's added // Note that the inputs are arrays of arrays, model /* previousUtxos = [{address: 'string', utxos: []}, ...] */ // Iterate over the currentUtxos array of {address: 'string', utxos: []} objects for (let i = 0; i < currentUtxos.length; i += 1) { // Take the first object const thisCurrentUtxoObject = currentUtxos[i]; const thisCurrentUtxoObjectAddress = thisCurrentUtxoObject.address; const thisCurrentUtxoObjectUtxos = thisCurrentUtxoObject.utxos; // Iterate over the previousUtxos array of {address: 'string', utxos: []} objects for (let j = 0; j < previousUtxos.length; j += 1) { const thisPreviousUtxoObject = previousUtxos[j]; const thisPreviousUtxoObjectAddress = thisPreviousUtxoObject.address; // When you find the utxos object at the same address if ( thisCurrentUtxoObjectAddress === thisPreviousUtxoObjectAddress ) { // Create a utxosAddedObject with the address const utxosAddedObject = { address: thisCurrentUtxoObjectAddress, utxos: [], }; utxosAdded.push(utxosAddedObject); // Grab the previousUtxoObject utxos array. thisCurrentUtxoObjectUtxos has changed compared to thisPreviousUtxoObjectUtxos const thisPreviousUtxoObjectUtxos = thisPreviousUtxoObject.utxos; // To see if any utxos exist in thisCurrentUtxoObjectUtxos that do not exist in thisPreviousUtxoObjectUtxos // iterate over thisPreviousUtxoObjectUtxos for each utxo in thisCurrentUtxoObjectUtxos for (let k = 0; k < thisCurrentUtxoObjectUtxos.length; k += 1) { const thisCurrentUtxo = thisCurrentUtxoObjectUtxos[k]; if ( isExcludedUtxo( thisCurrentUtxo, thisPreviousUtxoObjectUtxos, ) ) { // If thisCurrentUtxo was not in the corresponding previous utxos // Then it was added utxosAdded[j].utxos.push(thisCurrentUtxo); utxosAddedFlag = true; } } } } } // If utxos were added, return them if (utxosAddedFlag) { return utxosAdded; } // Else return false return utxosAddedFlag; }; export const whichUtxosWereConsumed = (previousUtxos, currentUtxos) => { let utxosConsumedFlag = false; const utxosConsumed = []; // Iterate over previousUtxos // For each previousUtxo -- does it exist in currentUtxos? // If no, it's consumed // Note that the inputs are arrays of arrays, model /* previousUtxos = [{address: 'string', utxos: []}, ...] */ // Iterate over the previousUtxos array of {address: 'string', utxos: []} objects for (let i = 0; i < previousUtxos.length; i += 1) { // Take the first object const thisPreviousUtxoObject = previousUtxos[i]; const thisPreviousUtxoObjectAddress = thisPreviousUtxoObject.address; const thisPreviousUtxoObjectUtxos = thisPreviousUtxoObject.utxos; // Iterate over the currentUtxos array of {address: 'string', utxos: []} objects for (let j = 0; j < currentUtxos.length; j += 1) { const thisCurrentUtxoObject = currentUtxos[j]; const thisCurrentUtxoObjectAddress = thisCurrentUtxoObject.address; // When you find the utxos object at the same address if ( thisCurrentUtxoObjectAddress === thisPreviousUtxoObjectAddress ) { // Create a utxosConsumedObject with the address const utxosConsumedObject = { address: thisCurrentUtxoObjectAddress, utxos: [], }; utxosConsumed.push(utxosConsumedObject); // Grab the currentUtxoObject utxos array. thisCurrentUtxoObjectUtxos has changed compared to thisPreviousUtxoObjectUtxos const thisCurrentUtxoObjectUtxos = thisCurrentUtxoObject.utxos; // To see if any utxos exist in thisPreviousUtxoObjectUtxos that do not exist in thisCurrentUtxoObjectUtxos // iterate over thisCurrentUtxoObjectUtxos for each utxo in thisPreviousUtxoObjectUtxos for ( let k = 0; k < thisPreviousUtxoObjectUtxos.length; k += 1 ) { const thisPreviousUtxo = thisPreviousUtxoObjectUtxos[k]; // If thisPreviousUtxo was not in the corresponding current utxos if ( isExcludedUtxo( thisPreviousUtxo, thisCurrentUtxoObjectUtxos, ) ) { // Then it was consumed utxosConsumed[j].utxos.push(thisPreviousUtxo); utxosConsumedFlag = true; } } } } } // If utxos were consumed, return them if (utxosConsumedFlag) { return utxosConsumed; } // Else return false return utxosConsumedFlag; }; export const addNewHydratedUtxos = ( addedHydratedUtxos, hydratedUtxoDetails, ) => { const theseAdditionalHydratedUtxos = addedHydratedUtxos.slpUtxos; for (let i = 0; i < theseAdditionalHydratedUtxos.length; i += 1) { const thisHydratedUtxoObj = theseAdditionalHydratedUtxos[i]; hydratedUtxoDetails.slpUtxos.push(thisHydratedUtxoObj); } return hydratedUtxoDetails; // Add hydrateUtxos(addedUtxos) to hydratedUtxoDetails /* e.g. add this { "slpUtxos": [ { "utxos": [ { "height": 725886, "tx_hash": "29985c01444bf80ade764e5d40d7ec2c12317e03301243170139c75f20c51f78", "tx_pos": 0, "value": 3300, "txid": "29985c01444bf80ade764e5d40d7ec2c12317e03301243170139c75f20c51f78", "vout": 0, "isValid": false } ], "address": "bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr" } ] } to this { "slpUtxos": [ { "utxos": [ { "height": 725886, "tx_hash": "29985c01444bf80ade764e5d40d7ec2c12317e03301243170139c75f20c51f78", "tx_pos": 0, "value": 3300, "txid": "29985c01444bf80ade764e5d40d7ec2c12317e03301243170139c75f20c51f78", "vout": 0, "isValid": false } ... up to 20 ], "address": "bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr" }, { "utxos": [ { "height": 725886, "tx_hash": "29985c01444bf80ade764e5d40d7ec2c12317e03301243170139c75f20c51f78", "tx_pos": 0, "value": 3300, "txid": "29985c01444bf80ade764e5d40d7ec2c12317e03301243170139c75f20c51f78", "vout": 0, "isValid": false } ... up to 20 ], "address": "bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr" } , ... a bunch of these in batches of 20 ] } */ }; export const removeConsumedUtxos = (consumedUtxos, hydratedUtxoDetails) => { let hydratedUtxoDetailsWithConsumedUtxosRemoved = hydratedUtxoDetails; const slpUtxosArray = hydratedUtxoDetails.slpUtxos; // Iterate over consumedUtxos // Every utxo in consumedUtxos must be removed from hydratedUtxoDetails for (let i = 0; i < consumedUtxos.length; i += 1) { const thisConsumedUtxoObject = consumedUtxos[i]; // {address: 'string', utxos: [{},{},...{}]} const thisConsumedUtxoObjectAddr = thisConsumedUtxoObject.address; const thisConsumedUtxoObjectUtxoArray = thisConsumedUtxoObject.utxos; for (let j = 0; j < thisConsumedUtxoObjectUtxoArray.length; j += 1) { const thisConsumedUtxo = thisConsumedUtxoObjectUtxoArray[j]; // Iterate through slpUtxosArray to find thisConsumedUtxo slpUtxosArrayLoop: for ( let k = 0; k < slpUtxosArray.length; k += 1 ) { const thisSlpUtxosArrayUtxoObject = slpUtxosArray[k]; // {address: 'string', utxos: [{},{},...{}]} const thisSlpUtxosArrayUtxoObjectAddr = thisSlpUtxosArrayUtxoObject.address; // If this address matches the address of the consumed utxo, check for a consumedUtxo match // Note, slpUtxos may have many utxo objects with the same address, need to check them all until you find and remove this consumed utxo if ( thisConsumedUtxoObjectAddr === thisSlpUtxosArrayUtxoObjectAddr ) { const thisSlpUtxosArrayUtxoObjectUtxoArray = thisSlpUtxosArrayUtxoObject.utxos; // Iterate to find it and remove it for ( let m = 0; m < thisSlpUtxosArrayUtxoObjectUtxoArray.length; m += 1 ) { const thisHydratedUtxo = thisSlpUtxosArrayUtxoObjectUtxoArray[m]; if ( thisConsumedUtxo.tx_hash === thisHydratedUtxo.tx_hash && thisConsumedUtxo.tx_pos === thisHydratedUtxo.tx_pos && thisConsumedUtxo.value === thisHydratedUtxo.value ) { // remove it hydratedUtxoDetailsWithConsumedUtxosRemoved.slpUtxos[ k ].utxos.splice(m, 1); // go to the next consumedUtxo break slpUtxosArrayLoop; } } } } } } return hydratedUtxoDetailsWithConsumedUtxosRemoved; }; export const getUtxoCount = utxos => { // return how many utxos // return false if input is invalid /* Both utxos and hydratedUtxoDetails.slpUtxos are build like so [ { address: 'string', utxos: [{}, {}, {}...{}] }, { address: 'string', utxos: [{}, {}, {}...{}] }, { address: 'string', utxos: [{}, {}, {}...{}] }, ] We want a function that quickly determines how many utxos are here */ // First, validate that you are getting a valid bch-api utxo set // if you are not, then return false -- which would cause areAllUtxosIncludedInIncrementallyHydratedUtxos to return false and calculate utxo set the legacy way const isValidUtxoObject = isValidBchApiUtxoObject(utxos); if (!isValidUtxoObject) { return false; } let utxoCount = 0; for (let i = 0; i < utxos.length; i += 1) { const thisUtxoArrLength = utxos[i].utxos.length; utxoCount += thisUtxoArrLength; } return utxoCount; }; export const areAllUtxosIncludedInIncrementallyHydratedUtxos = ( utxos, incrementallyHydratedUtxos, ) => { let incrementallyHydratedUtxosIncludesAllUtxosInLatestUtxoApiFetch = false; // check const { slpUtxos } = incrementallyHydratedUtxos; // Iterate over utxos array for (let i = 0; i < utxos.length; i += 1) { const thisUtxoObject = utxos[i]; const thisUtxoObjectAddr = thisUtxoObject.address; const thisUtxoObjectUtxos = thisUtxoObject.utxos; let utxoFound; for (let j = 0; j < thisUtxoObjectUtxos.length; j += 1) { const thisUtxo = thisUtxoObjectUtxos[j]; utxoFound = false; // Now iterate over slpUtxos to find it slpUtxosLoop: for (let k = 0; k < slpUtxos.length; k += 1) { const thisSlpUtxosObject = slpUtxos[k]; const thisSlpUtxosObjectAddr = thisSlpUtxosObject.address; if (thisUtxoObjectAddr === thisSlpUtxosObjectAddr) { const thisSlpUtxosObjectUtxos = thisSlpUtxosObject.utxos; for ( let m = 0; m < thisSlpUtxosObjectUtxos.length; m += 1 ) { const thisSlpUtxo = thisSlpUtxosObjectUtxos[m]; if ( thisUtxo.tx_hash === thisSlpUtxo.tx_hash && thisUtxo.tx_pos === thisSlpUtxo.tx_pos && thisUtxo.value === thisSlpUtxo.value ) { utxoFound = true; // goto next utxo break slpUtxosLoop; } } } if (k === slpUtxos.length - 1 && !utxoFound) { // return false return incrementallyHydratedUtxosIncludesAllUtxosInLatestUtxoApiFetch; } } } } // It's possible that hydratedUtxoDetails includes every utxo from the utxos array, but for some reason also includes additional utxos const utxosInUtxos = getUtxoCount(utxos); const utxosInIncrementallyHydratedUtxos = getUtxoCount(slpUtxos); if ( !utxosInUtxos || !utxosInIncrementallyHydratedUtxos || utxosInUtxos !== utxosInIncrementallyHydratedUtxos ) { return incrementallyHydratedUtxosIncludesAllUtxosInLatestUtxoApiFetch; } // If you make it here, good to go incrementallyHydratedUtxosIncludesAllUtxosInLatestUtxoApiFetch = true; return incrementallyHydratedUtxosIncludesAllUtxosInLatestUtxoApiFetch; }; diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js index 7a795f4a8..cd45f3546 100644 --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -1,389 +1,436 @@ import BigNumber from 'bignumber.js'; import { currency } from 'components/Common/Ticker.js'; import { fromSmallestDenomination } from 'utils/cashMethods'; import cashaddr from 'ecashaddrjs'; // 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(fromSmallestDenomination(currency.dustSats).toString()) ) { error = `Send amount must be at least ${fromSmallestDenomination( 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 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 isValidTokenStats = tokenStats => { return ( typeof tokenStats === 'object' && 'timestampUnix' in tokenStats && 'documentUri' in tokenStats && 'containsBaton' in tokenStats && 'initialTokenQty' in tokenStats && 'totalMinted' in tokenStats && 'totalBurned' in tokenStats && 'circulatingSupply' in tokenStats ); }; 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 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 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 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) >= fromSmallestDenomination(currency.dustSats) ); }; export const isValidUtxo = utxo => { let isValidUtxo = false; try { isValidUtxo = 'height' in utxo && typeof utxo.height === 'number' && 'tx_hash' in utxo && typeof utxo.tx_hash === 'string' && 'tx_pos' in utxo && typeof utxo.tx_pos === 'number' && 'value' in utxo && typeof utxo.value === 'number'; } catch (err) { return false; } return isValidUtxo; }; export const isValidBchApiUtxoObject = bchApiUtxoObject => { /* [ { address: 'string', utxos: [{}, {}, {}...{}] }, { address: 'string', utxos: [{}, {}, {}...{}] }, { address: 'string', utxos: [{}, {}, {}...{}] }, ] */ let isValidBchApiUtxoObject = false; // Must be an array if (!Array.isArray(bchApiUtxoObject)) { return isValidBchApiUtxoObject; } // Do not accept an empty array if (bchApiUtxoObject.length < 1) { return isValidBchApiUtxoObject; } for (let i = 0; i < bchApiUtxoObject.length; i += 1) { let thisUtxoObject = bchApiUtxoObject[i]; if ('address' in thisUtxoObject && 'utxos' in thisUtxoObject) { const thisUtxoArray = thisUtxoObject.utxos; if (Array.isArray(thisUtxoArray)) { // do not validate each individual utxo in the array // we are only validating the object structure here continue; } else { return isValidBchApiUtxoObject; } } else { return isValidBchApiUtxoObject; } } isValidBchApiUtxoObject = true; return isValidBchApiUtxoObject; }; 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 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( fromSmallestDenomination(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; };