diff --git a/web/cashtab/src/components/Home/Home.js b/web/cashtab/src/components/Home/Home.js --- a/web/cashtab/src/components/Home/Home.js +++ b/web/cashtab/src/components/Home/Home.js @@ -141,7 +141,8 @@ 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'); @@ -190,6 +191,7 @@ ? cashtabSettings.fiatCurrency : 'usd' } + contactList={contactList} /> {!hasHistory && ( <> diff --git a/web/cashtab/src/components/Home/Tx.js b/web/cashtab/src/components/Home/Tx.js --- a/web/cashtab/src/components/Home/Tx.js +++ b/web/cashtab/src/components/Home/Tx.js @@ -323,7 +323,12 @@ 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() @@ -590,8 +595,22 @@ + {!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 @@ -749,6 +768,7 @@ 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 --- a/web/cashtab/src/components/Home/TxHistory.js +++ b/web/cashtab/src/components/Home/TxHistory.js @@ -1,8 +1,11 @@ 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 => ( @@ -11,6 +14,7 @@ data={tx} fiatPrice={fiatPrice} fiatCurrency={fiatCurrency} + addressesInContactList={addressesInContactList} /> ))}
@@ -21,6 +25,12 @@ 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 --- a/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap +++ b/web/cashtab/src/components/Home/__tests__/__snapshots__/Home.test.js.snap @@ -3,15 +3,15 @@ exports[`Wallet with BCH balances 1`] = ` Array [

MigrationTestAlpha

0 @@ -19,26 +19,26 @@
,
@@ -87,15 +87,15 @@ exports[`Wallet with BCH balances and tokens 1`] = ` Array [

MigrationTestAlpha

0 @@ -103,26 +103,26 @@
,
@@ -171,15 +171,15 @@ exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [

MigrationTestAlpha

0.06 @@ -187,26 +187,26 @@
,
@@ -274,15 +274,15 @@ exports[`Wallet without BCH balance 1`] = ` Array [

MigrationTestAlpha

0 @@ -290,26 +290,26 @@
,
diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -14,7 +14,7 @@ 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'; @@ -25,6 +25,7 @@ } 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); @@ -943,6 +944,33 @@ 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 => { @@ -1181,6 +1209,7 @@ useEffect(async () => { handleUpdateWallet(setWallet); + await loadContactList(); const initialSettings = await loadCashtabSettings(); initializeFiatPriceApi(initialSettings.fiatCurrency); }, []); @@ -1191,6 +1220,7 @@ fiatPrice, loading, apiError, + contactList, cashtabSettings, changeCashtabSettings, getActiveWalletFromLocalForage, diff --git a/web/cashtab/src/utils/__tests__/cashMethods.test.js b/web/cashtab/src/utils/__tests__/cashMethods.test.js --- a/web/cashtab/src/utils/__tests__/cashMethods.test.js +++ b/web/cashtab/src/utils/__tests__/cashMethods.test.js @@ -2,6 +2,7 @@ import { fromSmallestDenomination, batchArray, + flattenContactList, flattenBatchedHydratedUtxos, loadStoredWallet, isValidStoredWallet, @@ -820,4 +821,41 @@ 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 --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -19,6 +19,7 @@ isValidXecAirdrop, isValidAirdropOutputsArray, isValidAirdropExclusionArray, + isValidContactList, } from '../validation'; import { currency } from 'components/Common/Ticker.js'; import { fromSmallestDenomination } from 'utils/cashMethods'; @@ -613,4 +614,52 @@ 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 --- a/web/cashtab/src/utils/cashMethods.js +++ b/web/cashtab/src/utils/cashMethods.js @@ -3,6 +3,7 @@ isValidXecAddress, isValidEtokenAddress, isValidBchApiUtxoObject, + isValidContactList, } from 'utils/validation'; import BigNumber from 'bignumber.js'; import cashaddr from 'ecashaddrjs'; @@ -174,6 +175,23 @@ 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 diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -140,6 +140,53 @@ } }; +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