diff --git a/web/cashtab/src/components/Airdrop/__tests__/__snapshots__/Airdrop.test.js.snap b/web/cashtab/src/components/Airdrop/__tests__/__snapshots__/Airdrop.test.js.snap index e52cf2e77..9769b626f 100644 --- a/web/cashtab/src/components/Airdrop/__tests__/__snapshots__/Airdrop.test.js.snap +++ b/web/cashtab/src/components/Airdrop/__tests__/__snapshots__/Airdrop.test.js.snap @@ -1,495 +1,565 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
You currently have 0 XEC
Deposit some funds to use this feature
,
,
XEC Airdrop Calculator
, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
You currently have 0 XEC
Deposit some funds to use this feature
,
,
XEC Airdrop Calculator
, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
0.06 XEC
,
,
XEC Airdrop Calculator
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
You currently have 0 XEC
Deposit some funds to use this feature
,
,
XEC Airdrop Calculator
, ] `; exports[`Without wallet defined 1`] = ` Array [
+ + + edit.svg + + +
+
You currently have 0 XEC
Deposit some funds to use this feature
,
,
XEC Airdrop Calculator
, ] `; diff --git a/web/cashtab/src/components/Common/CustomIcons.js b/web/cashtab/src/components/Common/CustomIcons.js index 3a0d6a476..8042508aa 100644 --- a/web/cashtab/src/components/Common/CustomIcons.js +++ b/web/cashtab/src/components/Common/CustomIcons.js @@ -1,143 +1,152 @@ import * as React from 'react'; import styled from 'styled-components'; import { CopyOutlined, DollarOutlined, LoadingOutlined, WalletOutlined, QrcodeOutlined, SettingOutlined, LockOutlined, ContactsOutlined, } from '@ant-design/icons'; import { Image } from 'antd'; import { currency } from 'components/Common/Ticker'; import { ReactComponent as Send } from 'assets/send.svg'; import { ReactComponent as Receive } from 'assets/receive.svg'; import { ReactComponent as Genesis } from 'assets/flask.svg'; import { ReactComponent as Unparsed } from 'assets/alert-circle.svg'; import { ReactComponent as Home } from 'assets/home.svg'; import { ReactComponent as Settings } from 'assets/cog.svg'; import { ReactComponent as CopySolid } from 'assets/copy.svg'; import { ReactComponent as LinkSolid } from 'assets/external-link-square-alt.svg'; import { ReactComponent as Airdrop } from 'assets/airdrop-icon.svg'; import { ReactComponent as Pdf } from 'assets/file-pdf.svg'; import { ReactComponent as Plus } from 'assets/plus.svg'; import { ReactComponent as Download } from 'assets/download.svg'; +import { ReactComponent as Edit } from 'assets/edit.svg'; export const CashLoadingIcon = ; export const CashReceivedNotificationIcon = () => ( ); export const TokenReceivedNotificationIcon = () => ( ); export const MessageSignedNotificationIcon = () => ( ); export const ThemedCopyOutlined = styled(CopyOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const ThemedDollarOutlined = styled(DollarOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const ThemedWalletOutlined = styled(WalletOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const ThemedQrcodeOutlined = styled(QrcodeOutlined)` color: ${props => props.theme.walletBackground} !important; `; export const ThemedSettingOutlined = styled(SettingOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const ThemedLockOutlined = styled(LockOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const ThemedContactsOutlined = styled(ContactsOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const ThemedContactSendOutlined = styled(Send)` color: ${props => props.theme.icons.outlined} !important; transform: rotate(-35deg); padding: 0.15rem 0rem 0.18rem 0rem; height: 1.3em; width: 1.3em; `; export const ThemedCopySolid = styled(CopySolid)` fill: ${props => props.theme.contrast}; padding: 0rem 0rem 0.27rem 0rem; height: 1.3em; width: 1.3em; `; export const ThemedLinkSolid = styled(LinkSolid)` fill: ${props => props.theme.contrast}; padding: 0.15rem 0rem 0.18rem 0rem; height: 1.3em; width: 1.3em; `; export const ThemedPdfSolid = styled(Pdf)` fill: ${props => props.theme.contrast}; padding: 0.15rem 0rem 0.18rem 0rem; height: 1.3em; width: 1.3em; `; export const ThemedPlusOutlined = styled(Plus)` fill: ${props => props.theme.contrast}; padding: 0.15rem 0rem 0.18rem 0rem; height: 1.3em; width: 1.3em; `; export const ThemedDownloadOutlined = styled(Download)` fill: ${props => props.theme.contrast}; padding: 0.15rem 0rem 0.18rem 0rem; height: 1.3em; width: 1.3em; `; +export const ThemedEditOutlined = styled(Edit)` + stroke: ${props => props.theme.eCashBlue}; + fill: ${props => props.theme.eCashBlue}; + min-width: 20px; + min-height: 20px; + cursor: pointer; +`; + export const LoadingBlock = styled.div` width: 100%; display: flex; align-items: center; justify-content: center; padding: 24px; flex-direction: column; svg { width: 50px; height: 50px; fill: ${props => props.theme.eCashBlue}; } `; export const CashLoader = () => ( ); export const ReceiveIcon = () => ; export const GenesisIcon = () => ; export const UnparsedIcon = () => ; export const HomeIcon = () => ; export const SettingsIcon = () => ; export const AirdropIcon = () => ; export const SendIcon = styled(Send)` transform: rotate(-35deg); `; export const CustomSpinner = ; diff --git a/web/cashtab/src/components/Common/WalletLabel.js b/web/cashtab/src/components/Common/WalletLabel.js index 2d01ee92c..ebe0c4461 100644 --- a/web/cashtab/src/components/Common/WalletLabel.js +++ b/web/cashtab/src/components/Common/WalletLabel.js @@ -1,29 +1,48 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; +import { ThemedEditOutlined } from 'components/Common/CustomIcons'; +import { Link } from 'react-router-dom'; + +const LabelCtn = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 3%; +`; const WalletName = styled.h4` font-size: 16px; display: inline-block; color: ${props => props.theme.lightWhite}; - margin-bottom: 0px; + margin-bottom: 2px; @media (max-width: 400px) { font-size: 16px; } `; const WalletLabel = ({ name }) => { return ( - <> + {name && typeof name === 'string' && ( {name} )} - + + + + ); }; WalletLabel.propTypes = { name: PropTypes.string, }; export default WalletLabel; diff --git a/web/cashtab/src/components/Configure/Configure.js b/web/cashtab/src/components/Configure/Configure.js index 12ee6a7ab..2ab060f31 100644 --- a/web/cashtab/src/components/Configure/Configure.js +++ b/web/cashtab/src/components/Configure/Configure.js @@ -1,1905 +1,1950 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import { useLocation, Link } from 'react-router-dom'; import { errorNotification, generalNotification, } from 'components/Common/Notifications'; import { Collapse, Form, Input, Modal, Alert, Switch, Tag, Tooltip, } from 'antd'; import { Row, Col } from 'antd'; import { PlusSquareOutlined, WalletFilled, ImportOutlined, LockOutlined, CheckOutlined, CloseOutlined, LockFilled, ExclamationCircleFilled, } from '@ant-design/icons'; import { WalletContext, AuthenticationContext } from 'utils/context'; import { SidePaddingCtn, FormLabel } from 'components/Common/Atoms'; import { StyledCollapse } from 'components/Common/StyledCollapse'; import { AntdFormWrapper, CurrencySelectDropdown, } from 'components/Common/EnhancedInputs'; import PrimaryButton, { SecondaryButton, SmartButton, } from 'components/Common/PrimaryButton'; import { ThemedCopyOutlined, ThemedWalletOutlined, ThemedDollarOutlined, ThemedSettingOutlined, ThemedContactsOutlined, ThemedContactSendOutlined, ThemedPlusOutlined, ThemedDownloadOutlined, ThemedCopySolid, } from 'components/Common/CustomIcons'; import { ReactComponent as Trashcan } from 'assets/trashcan.svg'; import { ReactComponent as Edit } from 'assets/edit.svg'; import { Event } from 'utils/GoogleAnalytics'; import ApiError from 'components/Common/ApiError'; import CopyToClipboard from 'components/Common/CopyToClipboard'; import { formatSavedBalance } from 'utils/formatting'; import { isValidXecAddress, isValidNewWalletNameLength, } from 'utils/validation'; import { convertToEcashPrefix } from 'utils/cashMethods'; import useWindowDimensions from 'hooks/useWindowDimensions'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { currency } from 'components/Common/Ticker.js'; const { Panel } = Collapse; const SettingsLinkCtn = styled.div` color: ${props => props.theme.lightWhite}; `; const SettingsLink = styled.a` text-decoration: underline; color: ${props => props.theme.eCashBlue}; :visited { text-decoration: underline; color: ${props => props.theme.eCashBlue}; } :hover { color: ${props => props.theme.eCashPurple}; } `; const SWRow = styled.div` border-radius: 3px; padding: 10px 0; display: flex; align-items: center; justify-content: center; margin-bottom: 6px; @media (max-width: 500px) { flex-direction: column; margin-bottom: 12px; } `; const SWName = styled.div` width: 50%; display: flex; align-items: center; justify-content: space-between; word-wrap: break-word; hyphens: auto; @media (max-width: 500px) { width: 100%; justify-content: center; margin-bottom: 15px; } h3 { font-size: 16px; color: ${props => props.theme.darkBlue}; margin: 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } h3.overflow { width: 100px; overflow: hidden; text-overflow: ellipsis; } `; const SWBalance = styled.div` width: 50%; display: flex; align-items: center; justify-content: space-between; word-wrap: break-word; hyphens: auto; @media (max-width: 500px) { width: 100%; justify-content: center; margin-bottom: 15px; } div { font-size: 13px; color: ${props => props.theme.darkBlue}; margin: 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } div.overflow { width: 150px; overflow: hidden; text-overflow: ellipsis; } `; const SWButtonCtn = styled.div` width: 50%; display: flex; align-items: center; justify-content: flex-end; @media (max-width: 500px) { width: 100%; justify-content: center; } button { cursor: pointer; background: transparent; border: 1px solid #fff; box-shadow: none; color: #fff; border-radius: 3px; opacity: 0.6; transition: all 200ms ease-in-out; :hover { opacity: 1; background: ${props => props.theme.eCashBlue}; border-color: ${props => props.theme.eCashBlue}; } @media (max-width: 768px) { font-size: 14px; } } svg { stroke: ${props => props.theme.eCashBlue}; fill: ${props => props.theme.eCashBlue}; width: 20px; height: 25px; margin-right: 10px; cursor: pointer; :first-child:hover { stroke: ${props => props.theme.eCashBlue}; fill: ${props => props.theme.eCashBlue}; } :hover { stroke: ${props => props.theme.settings.delete}; fill: ${props => props.theme.settings.delete}; } } `; const ContactListRow = styled.div` border-radius: 3px; padding: 10px 0; display: flex; align-items: center; justify-content: center; margin-bottom: 6px; @media (max-width: 500px) { flex-direction: column; margin-bottom: 12px; } `; const ContactListAddress = styled.div` width: 40%; display: flex; align-items: center; justify-content: space-between; word-wrap: break-word; hyphens: auto; @media (max-width: 500px) { width: 100%; justify-content: center; margin-bottom: 15px; } div { font-size: 13px; color: ${props => props.theme.darkBlue}; margin: 12px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } div.overflow { width: 150px; overflow: hidden; text-overflow: ellipsis; } `; const ContactListName = styled.div` width: 30%; display: flex; align-items: center; justify-content: space-between; word-wrap: break-word; hyphens: auto; @media (max-width: 500px) { width: 100%; justify-content: center; margin-bottom: 15px; } div { font-size: 13px; color: ${props => props.theme.darkBlue}; margin: 0px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } div.overflow { width: 150px; overflow: hidden; text-overflow: ellipsis; } `; const ContactListCtn = styled.div` display: flex; align-items: center; justify-content: flex-end; @media (max-width: 500px) { width: 100%; justify-content: center; } ${ThemedCopySolid} { margin-top: 7px; } button { cursor: pointer; background: transparent; border: 1px solid #fff; box-shadow: none; color: #fff; border-radius: 3px; opacity: 0.6; transition: all 200ms ease-in-out; :hover { opacity: 1; background: ${props => props.theme.eCashBlue}; border-color: ${props => props.theme.eCashBlue}; } @media (max-width: 768px) { font-size: 14px; } } svg { stroke: ${props => props.theme.eCashBlue}; fill: ${props => props.theme.eCashBlue}; width: 25px; height: 25px; margin-right: 10px; cursor: pointer; :first-child:hover { stroke: ${props => props.theme.eCashBlue}; fill: ${props => props.theme.eCashBlue}; } :hover { stroke: ${props => props.theme.settings.delete}; fill: ${props => props.theme.settings.delete}; } } `; const ContactListBtnCtn = styled.div` display: flex; align-items: center; justify-content: center; gap: 4px; `; const ExpandedBtnText = styled.span` @media (max-width: 335px) { display: none; } `; const ContactListBtn = styled.button` display: flex; justify-content: center; align-items: center; cursor: pointer; background: transparent; border: 1px solid #fff; box-shadow: none; color: #fff; border-radius: 3px; opacity: 0.6; gap: 3px; transition: all 200ms ease-in-out; @media (max-width: 500px) { width: 100%; justify-content: center; } :hover { opacity: 1; background: ${props => props.theme.eCashBlue}; border-color: ${props => props.theme.eCashBlue}; } svg { fill: ${props => props.theme.contrast} !important; } `; const AWRow = styled.div` padding: 10px 0; display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; h3 { font-size: 16px; + flex: 1 1 0; display: inline-block; color: ${props => props.theme.darkBlue}; margin: 0; text-align: left; font-weight: bold; @media (max-width: 500px) { font-size: 14px; } } h4 { font-size: 16px; + flex: 1 1 0; display: inline-block; color: ${props => props.theme.eCashBlue} !important; margin: 0; text-align: right; } + ${SWButtonCtn} { + flex: 1 1 0; + } @media (max-width: 500px) { flex-direction: column; margin-bottom: 12px; } `; const StyledConfigure = styled.div` h2 { color: ${props => props.theme.contrast}; font-size: 25px; } svg { fill: ${props => props.theme.eCashBlue}; } p { color: ${props => props.theme.darkBlue}; } .ant-alert { color: ${props => props.theme.lightGrey} font-size: 14px; } .ant-collapse-header{ .anticon{ flex: 1; } .seedPhrase{ flex: 2; } } `; const StyledSpacer = styled.div` height: 1px; width: 100%; background-color: ${props => props.theme.lightWhite}; margin: 60px 0 50px; `; const HideableTextContainer = styled.div``; const AutoCameraTextCtn = styled.div` display: flex; white-space: nowrap; gap: 3px; `; const GeneralSettingsItem = styled.div` display: flex; align-items: center; justify-content: space-between; .ant-switch svg { fill: #717171; } .title { color: ${props => props.theme.contrast}; } .anticon { color: ${props => props.theme.contrast}; } .ant-switch { background-color: #bdbdbd; } .ant-switch-checked { background-color: ${props => props.theme.eCashBlue}; svg { fill: ${props => props.theme.contrast}; } } .SendConfirm { color: ${props => props.theme.lightWhite}; } ${AutoCameraTextCtn} { color: ${props => props.theme.lightWhite}; ${HideableTextContainer} { @media (max-width: 500px) { display: none; } } } .ShowMessages { color: ${props => props.theme.lightWhite}; } `; const Configure = () => { const ContextValue = React.useContext(WalletContext); const authentication = React.useContext(AuthenticationContext); const { wallet, apiError } = ContextValue; const location = useLocation(); const { addNewSavedWallet, activateWallet, - renameWallet, + renameSavedWallet, + renameActiveWallet, deleteWallet, validateMnemonic, getSavedWallets, cashtabSettings, changeCashtabSettings, getContactListFromLocalForage, updateContactListInLocalForage, } = ContextValue; const [savedWallets, setSavedWallets] = useState([]); const [formData, setFormData] = useState({ dirty: true, mnemonic: '', }); const [showRenameWalletModal, setShowRenameWalletModal] = useState(false); const [showDeleteWalletModal, setShowDeleteWalletModal] = useState(false); const [walletToBeRenamed, setWalletToBeRenamed] = useState(null); const [walletToBeDeleted, setWalletToBeDeleted] = useState(null); const [newWalletName, setNewWalletName] = useState(''); const [ confirmationOfWalletToBeDeleted, setConfirmationOfWalletToBeDeleted, ] = useState(''); const [newWalletNameIsValid, setNewWalletNameIsValid] = useState(null); const [walletDeleteValid, setWalletDeleteValid] = useState(null); const [seedInput, openSeedInput] = useState(false); const [showTranslationWarning, setShowTranslationWarning] = useState(false); const [savedWalletContactModal, setSavedWalletContactModal] = useState(false); const showPopulatedDeleteWalletModal = walletInfo => { setWalletToBeDeleted(walletInfo); setShowDeleteWalletModal(true); }; const showPopulatedRenameWalletModal = walletInfo => { setWalletToBeRenamed(walletInfo); setShowRenameWalletModal(true); }; const cancelRenameWallet = () => { // Delete form value setNewWalletName(''); setShowRenameWalletModal(false); }; const cancelDeleteWallet = () => { setWalletToBeDeleted(null); setConfirmationOfWalletToBeDeleted(''); setShowDeleteWalletModal(false); }; const updateSavedWallets = async activeWallet => { if (activeWallet) { let savedWallets; try { savedWallets = await getSavedWallets(activeWallet); setSavedWallets(savedWallets); } catch (err) { console.log(`Error in getSavedWallets()`); console.log(err); } } }; const [isValidMnemonic, setIsValidMnemonic] = useState(null); const [contactListArray, setContactListArray] = useState([{}]); const [showRenameContactModal, setShowRenameContactModal] = useState(false); const [contactToBeRenamed, setContactToBeRenamed] = useState(null); //object const [newContactNameIsValid, setNewContactNameIsValid] = useState(null); const [ confirmationOfContactToBeRenamed, setConfirmationOfContactToBeRenamed, ] = useState(''); const [showDeleteContactModal, setShowDeleteContactModal] = useState(false); const [contactAddressToDelete, setContactAddressToDelete] = useState(null); const [contactDeleteValid, setContactDeleteValid] = useState(null); const [ confirmationOfContactToBeDeleted, setConfirmationOfContactToBeDeleted, ] = useState(''); const [showManualAddContactModal, setShowManualAddContactModal] = useState(false); const [manualContactName, setManualContactName] = useState(''); const [manualContactAddress, setManualContactAddress] = useState(''); const [manualContactNameIsValid, setManualContactNameIsValid] = useState(null); const [manualContactAddressIsValid, setManualContactAddressIsValid] = useState(null); const { width } = useWindowDimensions(); const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); useEffect(() => { // Update savedWallets every time the active wallet changes updateSavedWallets(wallet); }, [wallet]); useEffect(async () => { const detectedBrowserLang = navigator.language; if (!detectedBrowserLang.includes('en-')) { setShowTranslationWarning(true); } + if ( + location && + location.state && + location.state.showRenameWalletModal + ) { + setShowRenameWalletModal(true); + setWalletToBeRenamed(wallet); + } + // if this was routed from Home screen's Add to Contact link if (location && location.state && location.state.contactToAdd) { let tempContactListArray; try { tempContactListArray = await getContactListFromLocalForage(); } catch (err) { console.log('Error in getContactListFromLocalForage()'); console.log(err); } // set default name for contact and sender as address let newContactObj = { name: location.state.contactToAdd.substring(6, 11), address: location.state.contactToAdd, }; if (!tempContactListArray || tempContactListArray.length === 0) { // no existing contact list in local storage tempContactListArray = [{}]; // instantiates to mitigate null pointer issues tempContactListArray.push(newContactObj); tempContactListArray.shift(); // remove the initial entry from instantiation generalNotification( location.state.contactToAdd + ' added to Contact List', 'Success', ); } else { // contact list exists in local storage // check if address already exists in contact list let duplicateContact = false; let tempContactListArrayLength = tempContactListArray.length; for (let i = 0; i < tempContactListArrayLength; i++) { if ( tempContactListArray[i].address === location.state.contactToAdd ) { errorNotification( null, location.state.contactToAdd + ' already exists in the Contact List', 'handleManualAddContactModalOk() error', ); duplicateContact = true; break; } } // in the edge case of a fresh new wallet on a fresh new browser, remove the initialization entry to avoid an undefined contact in array if ( tempContactListArray && tempContactListArray[0].address === undefined ) { tempContactListArray.shift(); } // if address does not exist on the contact list, add it if (!duplicateContact) { tempContactListArray.push(newContactObj); generalNotification( location.state.contactToAdd + ' added to Contact List', 'Success', ); } } // update local storage let updateContactListStatus; try { updateContactListStatus = await updateContactListInLocalForage( tempContactListArray, ); } catch (err) { console.log('Error in updateContactListInLocalForage()'); console.log(err); } if (!updateContactListStatus) { errorNotification( null, 'Error updating contact list in localforage', 'Updating localforage with contact list', ); } // commit to state for local operations setContactListArray(tempContactListArray); } else { // if this was just standard routing between cashtab components // i.e. Not via the Add to contacts action let loadContactListStatus; try { loadContactListStatus = await getContactListFromLocalForage(); } catch (err) { console.log('Error in getContactListFromLocalForage()'); console.log(err); } // in the edge case of a fresh new wallet on a fresh new browser, remove the initialization entry to avoid an undefined contact in array if ( loadContactListStatus && loadContactListStatus[0].address === undefined ) { loadContactListStatus.shift(); } setContactListArray(loadContactListStatus); } }, []); // Need this function to ensure that savedWallets are updated on new wallet creation const updateSavedWalletsOnCreate = async importMnemonic => { // Event("Category", "Action", "Label") // Track number of times a different wallet is activated Event('Configure.js', 'Create Wallet', 'New'); const walletAdded = await addNewSavedWallet(importMnemonic); if (!walletAdded) { Modal.error({ title: 'This wallet already exists!', content: 'Wallet not added', }); } else { Modal.success({ content: 'Wallet added to your saved wallets', }); } await updateSavedWallets(wallet); }; // Same here // TODO you need to lock UI here until this is complete // Otherwise user may try to load an already-loading wallet, wreak havoc with indexedDB const updateSavedWalletsOnLoad = async walletToActivate => { // Event("Category", "Action", "Label") // Track number of times a different wallet is activated Event('Configure.js', 'Activate', ''); await activateWallet(walletToActivate); }; async function submit() { setFormData({ ...formData, dirty: false, }); // Exit if no user input if (!formData.mnemonic) { return; } // Exit if mnemonic is invalid if (!isValidMnemonic) { return; } // Event("Category", "Action", "Label") // Track number of times a different wallet is activated Event('Configure.js', 'Create Wallet', 'Imported'); updateSavedWalletsOnCreate(formData.mnemonic); } const handleChange = e => { const { value, name } = e.target; // Validate mnemonic on change // Import button should be disabled unless mnemonic is valid setIsValidMnemonic(validateMnemonic(value)); setFormData(p => ({ ...p, [name]: value })); }; const changeWalletName = async () => { + let oldActiveWalletName; if (!isValidNewWalletNameLength(newWalletName)) { setNewWalletNameIsValid(false); return; } // Hide modal setShowRenameWalletModal(false); // Change wallet name console.log( `Changing wallet ${walletToBeRenamed.name} name to ${newWalletName}`, ); - const renameSuccess = await renameWallet( - walletToBeRenamed.name, - newWalletName, - ); + let renameSuccess; + + if (walletToBeRenamed.name === wallet.name) { + oldActiveWalletName = walletToBeRenamed.name; + renameSuccess = await renameActiveWallet( + wallet, + walletToBeRenamed.name, + newWalletName, + ); + } else { + renameSuccess = await renameSavedWallet( + walletToBeRenamed.name, + newWalletName, + ); + } if (renameSuccess) { Modal.success({ - content: `Wallet "${walletToBeRenamed.name}" renamed to "${newWalletName}"`, + content: `Wallet "${ + oldActiveWalletName !== undefined + ? oldActiveWalletName + : walletToBeRenamed.name + }" renamed to "${newWalletName}"`, }); } else { Modal.error({ content: `Rename failed. All wallets must have a unique name.`, }); } await updateSavedWallets(wallet); // Clear wallet name for form setNewWalletName(''); }; const deleteSelectedWallet = async () => { if (!walletDeleteValid && walletDeleteValid !== null) { return; } if ( confirmationOfWalletToBeDeleted !== `delete ${walletToBeDeleted.name}` ) { setWalletDeleteValid(false); return; } // Hide modal setShowDeleteWalletModal(false); // Change wallet name console.log(`Deleting wallet "${walletToBeDeleted.name}"`); const walletDeletedSuccess = await deleteWallet(walletToBeDeleted); if (walletDeletedSuccess) { Modal.success({ content: `Wallet "${walletToBeDeleted.name}" successfully deleted`, }); } else { Modal.error({ content: `Error deleting ${walletToBeDeleted.name}.`, }); } await updateSavedWallets(wallet); // Clear wallet delete confirmation from form setConfirmationOfWalletToBeDeleted(''); }; const handleWalletNameInput = e => { const { value } = e.target; // validation if (value && isValidNewWalletNameLength(value)) { setNewWalletNameIsValid(true); } else { setNewWalletNameIsValid(false); } setNewWalletName(value); }; const handleWalletToDeleteInput = e => { const { value } = e.target; if (value && value === `delete ${walletToBeDeleted.name}`) { setWalletDeleteValid(true); } else { setWalletDeleteValid(false); } setConfirmationOfWalletToBeDeleted(value); }; const handleAppLockToggle = checked => { if (checked) { // if there is an existing credential, that means user has registered // simply turn on the Authentication Required flag if (authentication.credentialId) { authentication.turnOnAuthentication(); } else { // there is no existing credential, that means user has not registered // user need to register authentication.signUp(); } } else { authentication.turnOffAuthentication(); } }; const handleContactNameInput = e => { const { value } = e.target; if ( value && value.length && value.length < currency.localStorageMaxCharacters ) { setNewContactNameIsValid(true); } else { setNewContactNameIsValid(false); } setConfirmationOfContactToBeRenamed(value); }; const handleRenameContact = contactObj => { if (!contactObj) { console.log( 'handleRenameContact() error: Invalid contact object for update', ); return; } setContactToBeRenamed(contactObj); setShowRenameContactModal(true); }; const handleRenameContactCancel = () => { setShowRenameContactModal(false); }; const handleRenameContactModalOk = () => { if ( !newContactNameIsValid || newContactNameIsValid === null || !contactToBeRenamed ) { return; } renameContactByName(contactToBeRenamed); setShowRenameContactModal(false); }; const renameContactByName = async contactObj => { // obtain reference to the contact object in the array let contactObjToUpdate = contactListArray.find( element => element.address === contactObj.address, ); // if a match was found if (contactObjToUpdate) { // update the contact name contactObjToUpdate.name = confirmationOfContactToBeRenamed; // update local object array and local storage setContactListArray(contactListArray); let updateContactListStatus; try { updateContactListStatus = await updateContactListInLocalForage( contactListArray, ); } catch (err) { console.log('Error in updateContactListInLocalForage()'); console.log(err); } if (!updateContactListStatus) { errorNotification( null, 'Unable to update localforage with updated contact list', 'Updating localforage with contact list', ); } } else { errorNotification( null, 'Unable to find contact in array', 'Updating localforage with contact list', ); } }; const handleSendModalToggle = checkedState => { changeCashtabSettings('sendModal', checkedState); }; const handleCameraOverride = checkedState => { changeCashtabSettings('autoCameraOn', checkedState); }; const handleUnknownSenderMsg = checkedState => { changeCashtabSettings('hideMessagesFromUnknownSenders', checkedState); }; const getContactNameByAddress = contactAddress => { if (!contactAddress) { return; } // filter contact from local contact list array const filteredContactList = contactListArray.filter( element => element.address === contactAddress, ); if (!filteredContactList) { return; } return filteredContactList[0].name; }; const deleteContactByAddress = async contactAddress => { if (!contactAddress) { return; } // filter contact from local contact list array const updatedContactList = contactListArray.filter( element => element.address !== contactAddress, ); // update local list setContactListArray(updatedContactList); // commit updated list to local storage let updateContactListStatus; try { updateContactListStatus = await updateContactListInLocalForage( updatedContactList, ); } catch (err) { console.log('Error in updateContactListInLocalForage()'); console.log(err); } if (updateContactListStatus) { generalNotification( contactAddressToDelete + ' removed from Contact List', 'Success', ); } else { errorNotification( null, 'Error removing ' + contactAddressToDelete + ' from Contact List', 'Updating localforage with contact list', ); } }; const handleDeleteContact = contactAddress => { if (!contactAddress) { console.log( 'handleDeleteContact() error: Invalid contact address for deletion', ); return; } setContactAddressToDelete(contactAddress); setShowDeleteContactModal(true); }; const handleDeleteContactModalCancel = () => { setShowDeleteContactModal(false); }; const handleDeleteContactModalOk = () => { if ( !contactDeleteValid || contactDeleteValid === null || !contactAddressToDelete ) { return; } setShowDeleteContactModal(false); deleteContactByAddress(contactAddressToDelete); }; const handleContactToDeleteInput = e => { const { value } = e.target; const contactName = getContactNameByAddress(contactAddressToDelete); if (value && value === 'delete ' + contactName) { setContactDeleteValid(true); } else { setContactDeleteValid(false); } setConfirmationOfContactToBeDeleted(value); }; const exportContactList = contactListArray => { if (!contactListArray) { errorNotification('Unable to export contact list'); return; } // convert object array into csv data let csvContent = 'data:text/csv;charset=utf-8,' + contactListArray.map( element => '\n' + element.name + '|' + element.address, ); // encode csv var encodedUri = encodeURI(csvContent); // hidden DOM node to set the default file name var csvLink = document.createElement('a'); csvLink.setAttribute('href', encodedUri); csvLink.setAttribute( 'download', 'Cashtab_Contacts_' + wallet.name + '.csv', ); document.body.appendChild(csvLink); csvLink.click(); }; const handleAddSavedWalletAsContactOk = async () => { let duplicateContact = false; let tempContactListArray = contactListArray; let newContactObj = { name: manualContactName, address: manualContactAddress, }; if (!tempContactListArray || tempContactListArray.length === 0) { // no existing contact list in local storage tempContactListArray = [{}]; // instantiates to mitigate null pointer issues tempContactListArray.push(newContactObj); tempContactListArray.shift(); // remove the initial entry from instantiation } else { // contact list exists in local storage // check if address already exists in contact list let tempContactListArrayLength = tempContactListArray.length; for (let i = 0; i < tempContactListArrayLength; i++) { if (tempContactListArray[i].address === manualContactAddress) { errorNotification( null, manualContactAddress + ' already exists in the Contact List', 'handleAddSavedWalletAsContactOk() error', ); duplicateContact = true; break; } } // if address does not exist on the contact list, add it if (!duplicateContact) { tempContactListArray.push(newContactObj); generalNotification( manualContactAddress + ' added to Contact List', 'Success', ); } } // update localforage try { await updateContactListInLocalForage(tempContactListArray); } catch (err) { console.log('Error in handleAddSavedWalletAsContactOk()'); console.log(err); } // update local state array setContactListArray(tempContactListArray); setSavedWalletContactModal(false); setManualContactName(''); setManualContactAddress(''); }; const handleAddSavedWalletAsContactCancel = () => { setSavedWalletContactModal(false); setManualContactName(''); setManualContactAddress(''); }; const addSavedWalletToContact = walletInfo => { if (!walletInfo) { return; } // initialise saved wallet name and address to state for confirmation modal setManualContactName(walletInfo.name); setManualContactAddress( convertToEcashPrefix(walletInfo.Path1899.cashAddress), ); setSavedWalletContactModal(true); }; const handleManualAddContactModalOk = async () => { // if either inputs are invalid then go no further if (!manualContactNameIsValid || !manualContactAddressIsValid) { return; } let duplicateContact = false; let tempContactListArray = contactListArray; let newContactObj = { name: manualContactName, address: manualContactAddress, }; if (!tempContactListArray || tempContactListArray.length === 0) { // no existing contact list in local storage tempContactListArray = [{}]; // instantiates to mitigate null pointer issues tempContactListArray.push(newContactObj); tempContactListArray.shift(); // remove the initial entry from instantiation } else { // contact list exists in local storage // check if address already exists in contact list let tempContactListArrayLength = tempContactListArray.length; for (let i = 0; i < tempContactListArrayLength; i++) { if (tempContactListArray[i].address === manualContactAddress) { errorNotification( null, manualContactAddress + ' already exists in the Contact List', 'handleManualAddContactModalOk() error', ); duplicateContact = true; break; } } // if address does not exist on the contact list, add it if (!duplicateContact) { tempContactListArray.push(newContactObj); generalNotification( manualContactAddress + ' added to Contact List', 'Success', ); } } // update local state array setContactListArray(tempContactListArray); // update localforage try { await updateContactListInLocalForage(tempContactListArray); } catch (err) { console.log('Error in handleManualAddContactModalOk()'); console.log(err); } setShowManualAddContactModal(false); setManualContactName(''); setManualContactAddress(''); }; const handleManualAddContactModalCancel = () => { setShowManualAddContactModal(false); setManualContactName(''); setManualContactAddress(''); }; const handleManualContactNameInput = e => { const { value } = e.target; if (value && value.length && value.length < 24) { setManualContactNameIsValid(true); } else { setManualContactNameIsValid(false); } setManualContactName(value); }; const handleManualContactAddressInput = e => { const { value } = e.target; setManualContactAddressIsValid(isValidXecAddress(value)); setManualContactAddress(value); }; return ( {savedWalletContactModal && ( handleAddSavedWalletAsContactOk()} onCancel={() => handleAddSavedWalletAsContactCancel()} >
Name: {manualContactName}
Address: {manualContactAddress}
)} {showManualAddContactModal && ( handleManualAddContactModalOk()} onCancel={() => handleManualAddContactModalCancel()} >
Name: handleManualContactNameInput(e) } /> eCash Address: handleManualContactAddressInput(e) } />
)} {showDeleteContactModal && ( <> handleDeleteContactModalOk()} onCancel={() => handleDeleteContactModalCancel()} >

are you sure you want to delete{' '} {getContactNameByAddress( contactAddressToDelete, )}{' '} from contact list?

} placeholder={`Type "delete ${getContactNameByAddress( contactAddressToDelete, )}" to confirm`} name="contactToBeDeletedInput" value={ confirmationOfContactToBeDeleted } onChange={e => handleContactToDeleteInput(e) } />
)} {showRenameContactModal && ( handleRenameContactModalOk()} onCancel={() => handleRenameContactCancel()} >
} placeholder="Enter new contact name" name="newContactName" value={confirmationOfContactToBeRenamed} onChange={e => handleContactNameInput(e) } />
)} {walletToBeRenamed !== null && ( cancelRenameWallet()} >
} placeholder="Enter new wallet name" name="newName" value={newWalletName} onChange={e => handleWalletNameInput(e)} />
)} {walletToBeDeleted !== null && ( cancelDeleteWallet()} >
} placeholder={`Type "delete ${walletToBeDeleted.name}" to confirm`} name="walletToBeDeletedInput" value={confirmationOfWalletToBeDeleted} onChange={e => handleWalletToDeleteInput(e) } />
)}

Backup your wallet

{showTranslationWarning && ( )} {wallet && wallet.mnemonic && ( - + Click to reveal seed phrase } >

{wallet && wallet.mnemonic ? wallet.mnemonic : ''}

)}

Manage Wallets

{apiError ? ( ) : ( <> updateSavedWalletsOnCreate()} > New Wallet openSeedInput(!seedInput)} > Import Wallet {seedInput && ( <>

Copy and paste your mnemonic seed phrase below to import an existing wallet

} type="email" placeholder="mnemonic (seed phrase)" name="mnemonic" autoComplete="off" onChange={e => handleChange(e)} required title="" /> submit()} > Import
)} )} {savedWallets && savedWallets.length > 0 && ( <>

{wallet.name}

Currently active

+ + + showPopulatedRenameWalletModal( + wallet, + ) + } + /> + + addSavedWalletToContact(wallet) + } + /> +
{savedWallets.map(sw => (

{sw.name}

[ {sw && sw.state ? formatSavedBalance( sw.state.balances .totalBalance, ) : 'N/A'}{' '} XEC]
showPopulatedRenameWalletModal( sw, ) } /> addSavedWalletToContact( sw, ) } /> showPopulatedDeleteWalletModal( sw, ) } />
))}
)}
{contactListArray && contactListArray.length > 0 ? ( contactListArray.map( (element, index) => (
{ element.name }
{ element.address }
handleRenameContact( element, ) } /> handleDeleteContact( element.address, ) } />
), ) ) : (

{ 'Your contact list is empty.' }

{ 'Contacts can be added by clicking on a received transaction and looking for the "Add to contacts" icon or via the "New Contact" button below.' }

)} {/* Export button will only show when there are contacts */} {contactListArray && contactListArray.length > 0 && ( exportContactList( contactListArray, ) } > Download CSV )}

setShowManualAddContactModal( true, ) } > Add Contact

Fiat Currency

changeCashtabSettings('fiatCurrency', fiatCode) } />

General Settings

Lock App
{authentication ? ( } unCheckedChildren={} checked={ authentication.isAuthenticationRequired && authentication.credentialId ? true : false } // checked={false} onChange={handleAppLockToggle} /> ) : ( }> Not Supported )}
Send Confirmations
} unCheckedChildren={} checked={ cashtabSettings ? cashtabSettings.sendModal : false } onChange={handleSendModalToggle} />
{scannerSupported && ( Auto-open camera{' '} on send } unCheckedChildren={} checked={ cashtabSettings ? cashtabSettings.autoCameraOn : false } onChange={handleCameraOverride} /> )}
Hide messages from unknown sender
} unCheckedChildren={} checked={ cashtabSettings ? cashtabSettings.hideMessagesFromUnknownSenders : false } onChange={handleUnknownSenderMsg} />
[ Documentation ]
); }; export default Configure; diff --git a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap index d598119ce..76b51fbb4 100644 --- a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap +++ b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap @@ -1,1129 +1,1129 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances and tokens 1`] = `

Backup your wallet

Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.

Manage Wallets

Contact List

Fiat Currency

US Dollar ($)

General Settings

Lock App
Not Supported
Send Confirmations
Hide messages from unknown sender
`; exports[`Without wallet defined 1`] = `

Backup your wallet

Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.

Manage Wallets

Contact List

Fiat Currency

US Dollar ($)

General Settings

Lock App
Not Supported
Send Confirmations
Hide messages from unknown sender
`; 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 951093a6c..98acfed37 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,505 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
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 -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
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 -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
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 -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
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/components/Receive/__tests__/__snapshots__/Receive.test.js.snap b/web/cashtab/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap index 94586fd48..749c87c7e 100644 --- a/web/cashtab/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap +++ b/web/cashtab/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap @@ -1,590 +1,590 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = `

Receive XEC

Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
XEC
eToken
`; exports[`Wallet with BCH balances and tokens 1`] = `

Receive XEC

Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
XEC
eToken
`; exports[`Wallet with BCH balances and tokens and state field 1`] = `

Receive XEC

Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
XEC
eToken
`; exports[`Wallet without BCH balance 1`] = `

Receive XEC

Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
ecash: qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
XEC
eToken
`; 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/components/Send/__tests__/__snapshots__/Send.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap index a881bcb94..a8662960b 100644 --- a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap @@ -1,2806 +1,2876 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
Verify Message
, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
Verify Message
, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
0.06 XEC
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
Verify Message
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
Verify Message
, ] `; exports[`Without wallet defined 1`] = ` Array [
+
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max

0 XEC

= $ NaN USD
Advanced
Sign Message
Verify Message
, ] `; diff --git a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap index 58f16e920..def9fd73e 100644 --- a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap @@ -1,238 +1,238 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances and tokens 1`] = `null`; exports[`Wallet with BCH balances and tokens and state field 1`] = `
6.001 TBS
`; exports[`Without wallet defined 1`] = `null`; diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap index 1b2085467..1fb086b28 100644 --- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap +++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap @@ -1,354 +1,354 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances and tokens and state field 1`] = `

Create a Token

Click, or drag file to this area to upload

Only jpg or png accepted

`; diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap index c3030a926..60d540795 100644 --- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap +++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap @@ -1,559 +1,629 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
0 XEC
,

Create a Token

You need at least 5.5 XEC ( $ NaN USD ) to create a token

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

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
0 XEC
,

Create a Token

You need at least 5.5 XEC ( $ NaN USD ) to create a token

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

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
0.06 XEC
,

Create a Token

Click, or drag file to this area to upload

Only jpg or png accepted

, ] `; exports[`Wallet without BCH balance 1`] = ` Array [
-

- MigrationTestAlpha -

+

+ MigrationTestAlpha +

+ + + edit.svg + + +
0 XEC
,

Create a Token

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, ] `; exports[`Without wallet defined 1`] = ` Array [
+
0 XEC
,

Create a Token

You need at least 5.5 XEC ( $ NaN USD ) to create a token

, ] `; diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js index a97392a72..961a107fb 100644 --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -1,1495 +1,1541 @@ import { useState, useEffect } from 'react'; import usePrevious from 'hooks/usePrevious'; import useInterval from './useInterval'; import useBCH from 'hooks/useBCH'; import BigNumber from 'bignumber.js'; import { loadStoredWallet, isValidStoredWallet, isLegacyMigrationRequired, whichUtxosWereAdded, whichUtxosWereConsumed, addNewHydratedUtxos, removeConsumedUtxos, areAllUtxosIncludedInIncrementallyHydratedUtxos, getHashArrayFromWallet, parseChronikTx, checkWalletForTokenInfo, isActiveWebsocket, getWalletBalanceFromUtxos, } from 'utils/cashMethods'; import { isValidCashtabSettings, isValidContactList, parseInvalidSettingsForMigration, } 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, xecReceivedNotificationWebsocket, eTokenReceivedNotification, } from 'components/Common/Notifications'; import { ChronikClient } from 'chronik-client'; // For XEC, eCash chain: const chronik = new ChronikClient(currency.chronikUrl); const useWallet = () => { const [walletRefreshInterval, setWalletRefreshInterval] = useState( currency.websocketDisconnectedRefreshInterval, ); const [wallet, setWallet] = useState(false); const [chronikWebsocket, setChronikWebsocket] = useState(null); const [contactList, setContactList] = useState([{}]); const [cashtabSettings, setCashtabSettings] = useState(false); const [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 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 hash160 = BCH.Address.toHash160(cashAddress); const slpAddress = BCH.SLP.Address.toSLPAddress(cashAddress); return { publicKey, hash160, 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"); // Check if walletRefreshInterval is set to 10, i.e. this was called by websocket tx detection // If walletRefreshInterval is 10, set it back to the usual refresh rate if (walletRefreshInterval === 10) { setWalletRefreshInterval( currency.websocketConnectedRefreshInterval, ); } try { if (!wallet) { return; } 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 || !Array.isArray(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 = getWalletBalanceFromUtxos( slpBalancesAndUtxos.nonSlpUtxos, ); 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) => { + const renameSavedWallet = async (oldName, newName) => { // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( - `Error in await localforage.getItem("savedWallets") in renameWallet`, + `Error in await localforage.getItem("savedWallets") in renameSavedWallet`, ); console.log(err); return false; } // Verify that no existing wallet has this name for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === newName) { // return an error return false; } } // change name of desired wallet for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === oldName) { // Replace the name of this entry with the new name savedWallets[i].name = newName; } } // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( - `Error in localforage.setItem("savedWallets", savedWallets) in renameWallet()`, + `Error in localforage.setItem("savedWallets", savedWallets) in renameSavedWallet()`, + ); + return false; + } + return true; + }; + + const renameActiveWallet = async (wallet, oldName, newName) => { + // Load savedWallets + let savedWallets; + try { + savedWallets = await localforage.getItem('savedWallets'); + } catch (err) { + console.log( + `Error in await localforage.getItem("savedWallets") in renameSavedWallet`, + ); + console.log(err); + return false; + } + // Verify that no existing wallet has this name + for (let i = 0; i < savedWallets.length; i += 1) { + if (savedWallets[i].name === newName) { + // return an error + return false; + } + } + if (wallet.name === oldName) { + wallet.name = newName; + setWallet(wallet); + } + + // change name of desired wallet + for (let i = 0; i < savedWallets.length; i += 1) { + if (savedWallets[i].name === oldName) { + // Replace the name of this entry with the new name + savedWallets[i].name = newName; + } + } + // resave savedWallets + try { + // Set walletName as the active wallet + await localforage.setItem('savedWallets', savedWallets); + await localforage.setItem('wallet', wallet); + } catch (err) { + console.log( + `Error in localforage.setItem("wallet", wallet) in renameActiveWallet()`, ); return false; } return true; }; const deleteWallet = async walletToBeDeleted => { // delete a wallet // returns true if wallet is successfully deleted // otherwise returns false // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in deleteWallet`, ); console.log(err); return false; } // Iterate over to find the wallet to be deleted // Verify that no existing wallet has this name let walletFoundAndRemoved = false; for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === walletToBeDeleted.name) { // Verify it has the same mnemonic too, that's a better UUID if (savedWallets[i].mnemonic === walletToBeDeleted.mnemonic) { // Delete it savedWallets.splice(i, 1); walletFoundAndRemoved = true; } } } // If you don't find the wallet, return false if (!walletFoundAndRemoved) { return false; } // Resave savedWallets less the deleted wallet try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", savedWallets) in deleteWallet()`, ); return false; } return true; }; const addNewSavedWallet = async importMnemonic => { // Add a new wallet to savedWallets from importMnemonic or just new wallet const lang = 'english'; // create 128 bit BIP39 mnemonic const Bip39128BitMnemonic = importMnemonic ? importMnemonic : 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; } }; // Parse chronik ws message for incoming tx notifications const processChronikWsMsg = async (msg, wallet, fiatPrice) => { // get the message type const { type } = msg; // For now, only act on "first seen" transactions, as the only logic to happen is first seen notifications // Dev note: Other chronik msg types // "BlockConnected", arrives as new blocks are found // "Confirmed", arrives as subscribed + seen txid is confirmed in a block if (type !== 'AddedToMempool') { return; } // If you see a tx from your subscribed addresses added to the mempool, then the wallet utxo set has changed // Update it setWalletRefreshInterval(10); // get txid info const txid = msg.txid; const txDetails = await chronik.tx(txid); // parse tx for notification const hash160Array = getHashArrayFromWallet(wallet); const parsedChronikTx = parseChronikTx(txDetails, hash160Array); if (parsedChronikTx.incoming) { if (parsedChronikTx.isEtokenTx) { try { // Get the tokenID const incomingTokenId = parsedChronikTx.slpMeta.tokenId; // Check cache for token info // NB this procedure will change when chronik utxo formatting is implemented let incomingTokenInfo = checkWalletForTokenInfo( incomingTokenId, wallet, ); let eTokenAmountReceived; if (!incomingTokenInfo) { // chronik call to genesis tx to get this info const tokenGenesisInfo = await chronik.tx( incomingTokenId, ); incomingTokenInfo = { decimals: tokenGenesisInfo.slpTxData.genesisInfo.decimals, name: tokenGenesisInfo.slpTxData.genesisInfo .tokenName, ticker: tokenGenesisInfo.slpTxData.genesisInfo .tokenTicker, }; } // Calculate eToken amount with decimals eTokenAmountReceived = new BigNumber( parsedChronikTx.etokenAmount, ).shiftedBy(-1 * incomingTokenInfo.decimals); // Send this info to the notification function eTokenReceivedNotification( currency, incomingTokenInfo.ticker, eTokenAmountReceived, incomingTokenInfo.name, ); } catch (err) { // In this case, no incoming tx notification is received // This is an acceptable error condition, no need to fallback to another notification option console.log( `Error parsing eToken data for incoming tx notification`, err, ); } } else { xecReceivedNotificationWebsocket( parsedChronikTx.xecAmount, cashtabSettings, fiatPrice, ); } } }; // Chronik websockets const initializeWebsocket = async (wallet, fiatPrice) => { // Because wallet is set to `false` before it is loaded, do nothing if you find this case // Also return and wait for legacy migration if wallet is not migrated const hash160Array = getHashArrayFromWallet(wallet); if (!wallet || !hash160Array) { return setChronikWebsocket(null); } // Initialize if not in state let ws = chronikWebsocket; if (ws === null) { ws = chronik.ws({ onMessage: msg => { processChronikWsMsg(msg, wallet, fiatPrice); }, onReconnect: e => { // Fired before a reconnect attempt is made: console.log( 'Reconnecting websocket, disconnection cause: ', e, ); }, onConnect: e => { console.log(`Chronik websocket connected`, e); console.log( `Websocket connected, adjusting wallet refresh interval to ${ currency.websocketConnectedRefreshInterval / 1000 }s`, ); setWalletRefreshInterval( currency.websocketConnectedRefreshInterval, ); }, }); // Wait for websocket to be connected: await ws.waitForOpen(); } else { /* If the websocket connection is not null, initializeWebsocket was called because one of the websocket's dependencies changed Update the onMessage method to get the latest dependencies (wallet, fiatPrice) */ ws.onMessage = msg => { processChronikWsMsg(msg, wallet, fiatPrice); }; } // Check if current subscriptions match current wallet let activeSubscriptionsMatchActiveWallet = true; const previousWebsocketSubscriptions = ws._subs; // If there are no previous subscriptions, then activeSubscriptionsMatchActiveWallet is certainly false if (previousWebsocketSubscriptions.length === 0) { activeSubscriptionsMatchActiveWallet = false; } else { const subscribedHash160Array = previousWebsocketSubscriptions.map( function (subscription) { return subscription.scriptPayload; }, ); // Confirm that websocket is subscribed to every address in wallet hash160Array for (let i = 0; i < hash160Array.length; i += 1) { if (!subscribedHash160Array.includes(hash160Array[i])) { activeSubscriptionsMatchActiveWallet = false; } } } // If you are already subscribed to the right addresses, exit here // You get to this situation if fiatPrice changed but wallet.mnemonic did not if (activeSubscriptionsMatchActiveWallet) { // Put connected websocket in state return setChronikWebsocket(ws); } // Unsubscribe to any active subscriptions console.log( `previousWebsocketSubscriptions`, previousWebsocketSubscriptions, ); if (previousWebsocketSubscriptions.length > 0) { for (let i = 0; i < previousWebsocketSubscriptions.length; i += 1) { const unsubHash160 = previousWebsocketSubscriptions[i].scriptPayload; ws.unsubscribe('p2pkh', unsubHash160); console.log(`ws.unsubscribe('p2pkh', ${unsubHash160})`); } } // Subscribe to addresses of current wallet for (let i = 0; i < hash160Array.length; i += 1) { ws.subscribe('p2pkh', hash160Array[i]); console.log(`ws.subscribe('p2pkh', ${hash160Array[i]})`); } // Put connected websocket in state return setChronikWebsocket(ws); }; const handleUpdateWallet = async setWallet => { await loadWalletFromStorageOnStartup(setWallet); }; const loadCashtabSettings = async () => { // get settings object from localforage let localSettings; try { localSettings = await localforage.getItem('settings'); // If there is no keyvalue pair in localforage with key 'settings' if (localSettings === null) { // Create one with the default settings from Ticker.js localforage.setItem('settings', currency.defaultSettings); // Set state to default settings setCashtabSettings(currency.defaultSettings); return currency.defaultSettings; } } catch (err) { console.log(`Error getting cashtabSettings`, err); // TODO If they do not exist, write them // TODO add function to change them setCashtabSettings(currency.defaultSettings); return currency.defaultSettings; } // If you found an object in localforage at the settings key, make sure it's valid if (isValidCashtabSettings(localSettings)) { setCashtabSettings(localSettings); return localSettings; } // If a settings object is present but invalid, parse to find and add missing keys let modifiedLocalSettings = parseInvalidSettingsForMigration(localSettings); if (isValidCashtabSettings(modifiedLocalSettings)) { // modifiedLocalSettings placed in local storage localforage.setItem('settings', modifiedLocalSettings); setCashtabSettings(modifiedLocalSettings); // update missing key in local storage without overwriting existing valid settings return modifiedLocalSettings; } else { // if not valid, also set cashtabSettings to default setCashtabSettings(currency.defaultSettings); // Since this is returning default settings based on an error from reading storage, do not overwrite whatever is in storage return currency.defaultSettings; } }; const loadContactList = async () => { // get contactList object from localforage let localContactList; try { localContactList = await localforage.getItem('contactList'); // If there is no keyvalue pair in localforage with key '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 useInterval function, and re-sets to false during activateWallet // Do not show this notification if websocket connection is live; in this case the websocket will handle it if ( !isActiveWebsocket(chronikWebsocket) && previousBalances && balances && 'totalBalance' in previousBalances && 'totalBalance' in balances && new BigNumber(balances.totalBalance) .minus(previousBalances.totalBalance) .gt(0) && hasUpdated ) { xecReceivedNotification( balances, previousBalances, cashtabSettings, fiatPrice, ); } // Parse for incoming eToken transactions // Do not show this notification if websocket connection is live; in this case the websocket will handle it if ( !isActiveWebsocket(chronikWebsocket) && tokens && tokens[0] && tokens[0].balance && previousTokens && previousTokens[0] && previousTokens[0].balance && hasUpdated === true ) { // If tokens length is greater than previousTokens length, a new token has been received // Note, a user could receive a new token, AND more of existing tokens in between app updates // In this case, the app will only notify about the new token // TODO better handling for all possible cases to cover this // TODO handle with websockets for better response time, less complicated calc if (tokens.length > previousTokens.length) { // Find the new token const tokenIds = tokens.map(({ tokenId }) => tokenId); const previousTokenIds = previousTokens.map( ({ tokenId }) => tokenId, ); //console.log(`tokenIds`, tokenIds); //console.log(`previousTokenIds`, previousTokenIds); // An array with the new token Id const newTokenIdArr = tokenIds.filter( tokenId => !previousTokenIds.includes(tokenId), ); // It's possible that 2 new tokens were received // To do, handle this case const newTokenId = newTokenIdArr[0]; //console.log(newTokenId); // How much of this tokenId did you get? // would be at // Find where the newTokenId is const receivedTokenObjectIndex = tokens.findIndex( x => x.tokenId === newTokenId, ); //console.log(`receivedTokenObjectIndex`, receivedTokenObjectIndex); // Calculate amount received //console.log(`receivedTokenObject:`, tokens[receivedTokenObjectIndex]); const receivedSlpQty = tokens[receivedTokenObjectIndex].balance.toString(); const receivedSlpTicker = tokens[receivedTokenObjectIndex].info.tokenTicker; const receivedSlpName = tokens[receivedTokenObjectIndex].info.tokenName; //console.log(`receivedSlpQty`, receivedSlpQty); // Notification if you received SLP if (receivedSlpQty > 0) { eTokenReceivedNotification( currency, receivedSlpTicker, receivedSlpQty, receivedSlpName, ); } // } else { // If tokens[i].balance > previousTokens[i].balance, a new SLP tx of an existing token has been received // Note that tokens[i].balance is of type BigNumber for (let i = 0; i < tokens.length; i += 1) { if (tokens[i].balance.gt(previousTokens[i].balance)) { // Received this token // console.log(`previousTokenId`, previousTokens[i].tokenId); // console.log(`currentTokenId`, tokens[i].tokenId); if (previousTokens[i].tokenId !== tokens[i].tokenId) { console.log( `TokenIds do not match, breaking from SLP notifications`, ); // Then don't send the notification // Also don't 'continue' ; this means you have sent a token, just stop iterating through break; } const receivedSlpQty = tokens[i].balance.minus( previousTokens[i].balance, ); const receivedSlpTicker = tokens[i].info.tokenTicker; const receivedSlpName = tokens[i].info.tokenName; eTokenReceivedNotification( currency, receivedSlpTicker, receivedSlpQty, receivedSlpName, ); } } } } // Update wallet according to defined interval useInterval(async () => { const wallet = await getWallet(); update({ wallet, }).finally(() => { setLoading(false); if (!hasUpdated) { setHasUpdated(true); } }); }, walletRefreshInterval); const fetchBchPrice = async ( fiatCode = cashtabSettings ? cashtabSettings.fiatCurrency : 'usd', ) => { // Split this variable out in case coingecko changes const cryptoId = currency.coingeckoId; // Keep this in the code, because different URLs will have different outputs require different parsing const priceApiUrl = `https://api.coingecko.com/api/v3/simple/price?ids=${cryptoId}&vs_currencies=${fiatCode}&include_last_updated_at=true`; let bchPrice; let bchPriceJson; try { bchPrice = await fetch(priceApiUrl); //console.log(`bchPrice`, bchPrice); } catch (err) { console.log(`Error fetching BCH Price`); console.log(err); } try { bchPriceJson = await bchPrice.json(); //console.log(`bchPriceJson`, bchPriceJson); let bchPriceInFiat = bchPriceJson[cryptoId][fiatCode]; const validEcashPrice = typeof bchPriceInFiat === 'number'; if (validEcashPrice) { setFiatPrice(bchPriceInFiat); } else { // If API price looks fishy, do not allow app to send using fiat settings setFiatPrice(null); } } catch (err) { console.log(`Error parsing price API response to JSON`); console.log(err); } }; useEffect(async () => { handleUpdateWallet(setWallet); await loadContactList(); const initialSettings = await loadCashtabSettings(); initializeFiatPriceApi(initialSettings.fiatCurrency); }, []); /* Run initializeWebsocket(wallet, fiatPrice) each time the wallet or fiatPrice changes Use wallet.mnemonic as the useEffect parameter here because we want to run initializeWebsocket(wallet, fiatPrice) when a new unique wallet is selected, not when the active wallet changes state */ useEffect(async () => { await initializeWebsocket(wallet, fiatPrice); }, [wallet.mnemonic, fiatPrice]); return { BCH, 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, + renameSavedWallet, + renameActiveWallet, deleteWallet, }; }; export default useWallet;