diff --git a/web/cashtab/src/components/Common/EnhancedInputs.js b/web/cashtab/src/components/Common/EnhancedInputs.js index c015dbed9..2bba5a13c 100644 --- a/web/cashtab/src/components/Common/EnhancedInputs.js +++ b/web/cashtab/src/components/Common/EnhancedInputs.js @@ -1,267 +1,311 @@ import * as React from 'react'; import { Form, Input, Select } from 'antd'; import { ThemedDollarOutlined, ThemedWalletOutlined, } from '@components/Common/CustomIcons'; import styled, { css } from 'styled-components'; import { ScanQRCode } from './ScanQRCode'; import useBCH from '@hooks/useBCH'; import { currency } from '@components/Common/Ticker.js'; export const AntdFormCss = css` .ant-input-group-addon { background-color: ${props => props.theme.forms.addonBackground} !important; border: 1px solid ${props => props.theme.forms.border}; color: ${props => props.theme.forms.addonForeground} !important; } input.ant-input, .ant-select-selection { background-color: ${props => props.theme.forms.selectionBackground} !important; box-shadow: none !important; border-radius: 4px; font-weight: bold; color: ${props => props.theme.forms.text}; opacity: 1; height: 50px; } .ant-input-affix-wrapper { background-color: ${props => props.theme.forms.selectionBackground}; border: 1px solid ${props => props.theme.wallet.borders.color} !important; } .ant-select-selector { height: 60px !important; border: 1px solid ${props => props.theme.wallet.borders.color} !important; } .ant-form-item-has-error > div > div.ant-form-item-control-input > div > span > span > span.ant-input-affix-wrapper { background-color: ${props => props.theme.forms.selectionBackground}; border-color: ${props => props.theme.forms.error} !important; } .ant-form-item-has-error .ant-input, .ant-form-item-has-error .ant-input-affix-wrapper, .ant-form-item-has-error .ant-input:hover, .ant-form-item-has-error .ant-input-affix-wrapper:hover { background-color: ${props => props.theme.forms.selectionBackground}; border-color: ${props => props.theme.forms.error} !important; } .ant-form-item-has-error .ant-select:not(.ant-select-disabled):not(.ant-select-customize-input) .ant-select-selector { background-color: ${props => props.theme.forms.selectionBackground}; border-color: ${props => props.theme.forms.error} !important; } .ant-select-single .ant-select-selector .ant-select-selection-item, .ant-select-single .ant-select-selector .ant-select-selection-placeholder { line-height: 60px; text-align: left; color: ${props => props.theme.forms.text}; font-weight: bold; } .ant-form-item-has-error .ant-input-group-addon { color: ${props => props.theme.forms.error} !important; border-color: ${props => props.theme.forms.error} !important; } .ant-form-item-explain.ant-form-item-explain-error { color: ${props => props.theme.forms.error} !important; } `; export const AntdFormWrapper = styled.div` ${AntdFormCss} `; export const InputAddonText = styled.span` width: 100%; height: 100%; display: block; ${props => props.disabled ? ` cursor: not-allowed; ` : `cursor: pointer;`} `; export const InputNumberAddonText = styled.span` background-color: ${props => props.theme.forms.addonBackground} !important; border: 1px solid ${props => props.theme.forms.border}; color: ${props => props.theme.forms.addonForeground} !important; height: 50px; line-height: 47px; * { color: ${props => props.theme.forms.addonForeground} !important; } ${props => props.disabled ? ` cursor: not-allowed; ` : `cursor: pointer;`} `; export const SendBchInput = ({ onMax, inputProps, selectProps, + activeFiatCode, ...otherProps }) => { const { Option } = Select; const currencies = [ { value: currency.ticker, label: currency.ticker, }, - { value: 'USD', label: 'USD' }, + { + value: activeFiatCode ? activeFiatCode : 'USD', + label: activeFiatCode ? activeFiatCode : 'USD', + }, ]; const currencyOptions = currencies.map(currency => { return ( ); }); const CurrencySelect = ( ); return ( ) : ( ) } {...inputProps} /> {CurrencySelect} max ); }; export const FormItemWithMaxAddon = ({ onMax, inputProps, ...otherProps }) => { return ( } addonAfter={ max } {...inputProps} /> ); }; // loadWithCameraOpen prop: if true, load page with camera scanning open export const FormItemWithQRCodeAddon = ({ onScan, loadWithCameraOpen, inputProps, ...otherProps }) => { return ( } autoComplete="off" addonAfter={ } {...inputProps} /> ); }; +export const CurrencySelectDropdown = selectProps => { + const { Option } = Select; + + // Build select dropdown from currency.fiatCurrencies + const currencyMenuOptions = []; + const currencyKeys = Object.keys(currency.fiatCurrencies); + for (let i = 0; i < currencyKeys.length; i += 1) { + const currencyMenuOption = {}; + currencyMenuOption.value = + currency.fiatCurrencies[currencyKeys[i]].slug; + currencyMenuOption.label = `${ + currency.fiatCurrencies[currencyKeys[i]].name + } (${currency.fiatCurrencies[currencyKeys[i]].symbol})`; + currencyMenuOptions.push(currencyMenuOption); + } + const currencyOptions = currencyMenuOptions.map(currencyMenuOption => { + return ( + + ); + }); + + return ( + + ); +}; + export const AddressValidators = () => { const { BCH } = useBCH(); return { safelyDetectAddressFormat: value => { try { return BCH.Address.detectAddressFormat(value); } catch (error) { return null; } }, isSLPAddress: value => AddressValidators.safelyDetectAddressFormat(value) === 'slpaddr', isBCHAddress: value => AddressValidators.safelyDetectAddressFormat(value) === 'cashaddr', isLegacyAddress: value => AddressValidators.safelyDetectAddressFormat(value) === 'legacy', }(); }; diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js index 58201830a..cdb33382e 100644 --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -1,144 +1,152 @@ import mainLogo from '@assets/logo_primary.png'; import tokenLogo from '@assets/logo_secondary.png'; import cashaddr from 'cashaddrjs'; import BigNumber from 'bignumber.js'; export const currency = { name: 'eCash', ticker: 'XEC', logo: mainLogo, legacyPrefix: 'bitcoincash', prefixes: ['bitcoincash', 'ecash'], coingeckoId: 'bitcoin-cash-abc-2', defaultFee: 2.01, dustSats: 546, cashDecimals: 2, blockExplorerUrl: 'https://explorer.bitcoinabc.org', tokenExplorerUrl: 'https://explorer.be.cash', blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org', tokenName: 'eToken', tokenTicker: 'eToken', tokenLogo: tokenLogo, tokenPrefixes: ['simpleledger', 'etoken'], tokenIconsUrl: '', //https://tokens.bitcoin.com/32 for BCH SLP useBlockchainWs: false, txHistoryCount: 5, hydrateUtxoBatchSize: 20, + defaultSettings: { fiatCurrency: 'usd' }, + settingsValidation: { fiatCurrency: ['usd', 'idr', 'krw', 'cny'] }, + fiatCurrencies: { + usd: { name: 'US Dollar', symbol: '$', slug: 'usd' }, + idr: { name: 'Indonesian Rupiah', symbol: 'Rp', slug: 'idr' }, + krw: { name: 'Korean Won', symbol: '₩', slug: 'krw' }, + cny: { name: 'Chinese Yuan', symbol: '元', slug: 'cny' }, + }, }; export function isValidCashPrefix(addressString) { // Note that this function validates prefix only // Check for prefix included in currency.prefixes array // For now, validation is handled by converting to bitcoincash: prefix and checksum // and relying on legacy validation methods of bitcoincash: prefix addresses // Also accept an address with no prefix, as some exchanges provide these for (let i = 0; i < currency.prefixes.length; i += 1) { // If the addressString being tested starts with an accepted prefix or no prefix at all if ( addressString.startsWith(currency.prefixes[i] + ':') || !addressString.includes(':') ) { return true; } } return false; } export function isValidTokenPrefix(addressString) { // Check for prefix included in currency.tokenPrefixes array // For now, validation is handled by converting to simpleledger: prefix and checksum // and relying on legacy validation methods of simpleledger: prefix addresses // For token addresses, do not accept an address with no prefix for (let i = 0; i < currency.tokenPrefixes.length; i += 1) { if (addressString.startsWith(currency.tokenPrefixes[i] + ':')) { return true; } } return false; } export function toLegacy(address) { let testedAddress; let legacyAddress; try { if (isValidCashPrefix(address)) { // Prefix-less addresses may be valid, but the cashaddr.decode function used below // will throw an error without a prefix. Hence, must ensure prefix to use that function. const hasPrefix = address.includes(':'); if (!hasPrefix) { testedAddress = currency.legacyPrefix + ':' + address; } else { testedAddress = address; } // Note: an `ecash:` checksum address with no prefix will not be validated by // parseAddress in Send.js // Only handle the case of prefixless address that is valid `bitcoincash:` address const { type, hash } = cashaddr.decode(testedAddress); legacyAddress = cashaddr.encode(currency.legacyPrefix, type, hash); } else { console.log(`Error: ${address} is not a cash address`); throw new Error( 'Address prefix is not a valid cash address with a prefix from the Ticker.prefixes array', ); } } catch (err) { return err; } return legacyAddress; } export function parseAddress(BCH, addressString) { // Build return obj const addressInfo = { address: '', isValid: false, queryString: null, amount: null, }; // Parse address string for parameters const paramCheck = addressString.split('?'); let cleanAddress = paramCheck[0]; addressInfo.address = cleanAddress; // Validate address let isValidAddress; try { isValidAddress = BCH.Address.isCashAddress(cleanAddress); } catch (err) { isValidAddress = false; } addressInfo.isValid = isValidAddress; // Check for parameters // only the amount param is currently supported let queryString = null; let amount = null; if (paramCheck.length > 1) { queryString = paramCheck[1]; addressInfo.queryString = queryString; const addrParams = new URLSearchParams(queryString); if (addrParams.has('amount')) { // Amount in satoshis try { amount = new BigNumber(parseInt(addrParams.get('amount'))) .div(1e8) .toString(); } catch (err) { amount = null; } } } addressInfo.amount = amount; return addressInfo; } diff --git a/web/cashtab/src/components/Configure/Configure.js b/web/cashtab/src/components/Configure/Configure.js index 50874002a..b55d1e47e 100644 --- a/web/cashtab/src/components/Configure/Configure.js +++ b/web/cashtab/src/components/Configure/Configure.js @@ -1,600 +1,622 @@ /* eslint-disable react-hooks/exhaustive-deps */ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import { Collapse, Form, Input, Modal, Spin, Alert } from 'antd'; import { PlusSquareOutlined, WalletFilled, ImportOutlined, LockOutlined, } from '@ant-design/icons'; import { WalletContext } from '@utils/context'; import { StyledCollapse } from '@components/Common/StyledCollapse'; -import { AntdFormWrapper } from '@components/Common/EnhancedInputs'; +import { + AntdFormWrapper, + CurrencySelectDropdown, +} from '@components/Common/EnhancedInputs'; import PrimaryButton, { SecondaryButton, SmartButton, } from '@components/Common/PrimaryButton'; import { CashLoader, CashLoadingIcon, ThemedCopyOutlined, ThemedWalletOutlined, + ThemedDollarOutlined, } 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'; const { Panel } = Collapse; const SettingsLink = styled.a` text-decoration: underline; color: ${props => props.theme.primary}; :visited { text-decoration: underline; color: ${props => props.theme.primary}; } :hover { color: ${props => props.theme.brandSecondary}; } `; 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.wallet.text.secondary}; margin: 0; text-align: left; white-space: nowrap; 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; @media (max-width: 768px) { font-size: 14px; } } svg { stroke: ${props => props.theme.wallet.text.secondary}; fill: ${props => props.theme.wallet.text.secondary}; width: 25px; height: 25px; margin-right: 20px; cursor: pointer; :first-child:hover { stroke: ${props => props.theme.primary}; fill: ${props => props.theme.primary}; } :hover { stroke: ${props => props.theme.settings.delete}; fill: ${props => props.theme.settings.delete}; } } `; const AWRow = styled.div` padding: 10px 0; display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; h3 { font-size: 16px; display: inline-block; color: ${props => props.theme.wallet.text.secondary}; margin: 0; text-align: left; font-weight: bold; @media (max-width: 500px) { font-size: 14px; } } h4 { font-size: 16px; display: inline-block; color: ${props => props.theme.primary} !important; margin: 0; text-align: right; } @media (max-width: 500px) { flex-direction: column; margin-bottom: 12px; } `; const StyledConfigure = styled.div` h2 { color: ${props => props.theme.wallet.text.primary}; font-size: 25px; } p { color: ${props => props.theme.wallet.text.secondary}; } `; const StyledSpacer = styled.div` height: 1px; width: 100%; background-color: ${props => props.theme.wallet.borders.color}; margin: 60px 0 50px; `; const Configure = () => { const ContextValue = React.useContext(WalletContext); const { wallet, loading, apiError } = ContextValue; const { addNewSavedWallet, activateWallet, renameWallet, deleteWallet, validateMnemonic, getSavedWallets, + cashtabSettings, + changeCashtabSettings, } = 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 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); useEffect(() => { // Update savedWallets every time the active wallet changes updateSavedWallets(wallet); }, [wallet]); // 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 () => { if (newWalletName === '' || newWalletName.length > 24) { 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, ); if (renameSuccess) { Modal.success({ content: `Wallet "${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 && value.length && value.length < 24) { 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); }; return ( {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

{wallet && wallet.mnemonic && (

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

)}

Manage Wallets

{apiError ? ( <>

An error occured on our end. Reconnecting...

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

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

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

{wallet.name}

Currently active

{savedWallets.map(sw => (

{sw.name}

showPopulatedRenameWalletModal( sw, ) } /> showPopulatedDeleteWalletModal( sw, ) } />
))}
)} + +

+ Fiat Currency +

+ + + changeCashtabSettings('fiatCurrency', fiatCode) + } + /> + [ 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 53933fdd4..012328fc3 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,437 +1,671 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Configure with a wallet 1`] = `

Backup your wallet

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

Manage Wallets

+

+ + + + Fiat Currency +

+
+
+
+ + + + + US Dollar ($) + +
+ + + + + +
+
+
`; exports[`Configure without a wallet 1`] = `

Backup your wallet

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

Manage Wallets

+

+ + + + Fiat Currency +

+
+
+
+ + + + + US Dollar ($) + +
+ + + + + +
+
+
`; diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js index 84dd9cc6f..1af002446 100644 --- a/web/cashtab/src/components/Send/Send.js +++ b/web/cashtab/src/components/Send/Send.js @@ -1,540 +1,572 @@ import React, { useState, useEffect } from 'react'; import { WalletContext } from '@utils/context'; import { Form, notification, message, Spin, Modal, Alert } from 'antd'; import { CashLoader, CashLoadingIcon } from '@components/Common/CustomIcons'; import { Row, Col } from 'antd'; import Paragraph from 'antd/lib/typography/Paragraph'; import PrimaryButton, { SecondaryButton, } from '@components/Common/PrimaryButton'; import { SendBchInput, FormItemWithQRCodeAddon, } from '@components/Common/EnhancedInputs'; import useBCH from '@hooks/useBCH'; import useWindowDimensions from '@hooks/useWindowDimensions'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { currency, isValidTokenPrefix, parseAddress, toLegacy, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; import { fiatToCrypto, shouldRejectAmountInput } from '@utils/validation'; import { formatBalance } from '@utils/cashMethods'; import { BalanceHeader, BalanceHeaderFiat, ZeroBalanceHeader, ConvertAmount, AlertMsg, } from '@components/Common/Atoms'; // Note jestBCH is only used for unit tests; BCHJS must be mocked for jest const SendBCH = ({ jestBCH, filledAddress, callbackTxId }) => { // use balance parameters from wallet.state object and not legacy balances parameter from walletState, if user has migrated wallet // this handles edge case of user with old wallet who has not opened latest Cashtab version yet // If the wallet object from ContextValue has a `state key`, then check which keys are in the wallet object // Else set it as blank const ContextValue = React.useContext(WalletContext); - const { wallet, fiatPrice, slpBalancesAndUtxos, apiError } = ContextValue; + const { + wallet, + fiatPrice, + slpBalancesAndUtxos, + apiError, + cashtabSettings, + } = ContextValue; let balances; const paramsInWalletState = wallet.state ? Object.keys(wallet.state) : []; // If wallet.state includes balances and parsedTxHistory params, use these // These are saved in indexedDb in the latest version of the app, hence accessible more quickly if (paramsInWalletState.includes('balances')) { balances = wallet.state.balances; } else { // If balances and parsedTxHistory are not in the wallet.state object, load them from Context // This is how the app used to work balances = ContextValue.balances; } // Get device window width // If this is less than 769, the page will open with QR scanner open const { width } = useWindowDimensions(); // Load with QR code open if device is mobile and NOT iOS + anything but safari const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); const [formData, setFormData] = useState({ dirty: true, value: '', address: filledAddress || '', }); const [loading, setLoading] = useState(false); const [queryStringText, setQueryStringText] = useState(null); const [sendBchAddressError, setSendBchAddressError] = useState(false); const [sendBchAmountError, setSendBchAmountError] = useState(false); const [selectedCurrency, setSelectedCurrency] = useState(currency.ticker); // Support cashtab button from web pages const [txInfoFromUrl, setTxInfoFromUrl] = useState(false); // Show a confirmation modal on transactions created by populating form from web page button const [isModalVisible, setIsModalVisible] = useState(false); const showModal = () => { setIsModalVisible(true); }; const handleOk = () => { setIsModalVisible(false); submit(); }; const handleCancel = () => { setIsModalVisible(false); }; const { getBCH, getRestUrl, sendBch, calcFee } = useBCH(); // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); const BCH = jestBCH ? jestBCH : getBCH(); // If the balance has changed, unlock the UI // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked useEffect(() => { setLoading(false); }, [balances.totalBalance]); useEffect(() => { // Manually parse for txInfo object on page load when Send.js is loaded with a query string // Do not set txInfo in state if query strings are not present if ( !window.location || !window.location.hash || window.location.hash === '#/send' ) { return; } const txInfoArr = window.location.hash.split('?')[1].split('&'); // Iterate over this to create object const txInfo = {}; for (let i = 0; i < txInfoArr.length; i += 1) { let txInfoKeyValue = txInfoArr[i].split('='); let key = txInfoKeyValue[0]; let value = txInfoKeyValue[1]; txInfo[key] = value; } console.log(`txInfo from page params`, txInfo); setTxInfoFromUrl(txInfo); populateFormsFromUrl(txInfo); }, []); function populateFormsFromUrl(txInfo) { if (txInfo && txInfo.address && txInfo.value) { setFormData({ address: txInfo.address, value: txInfo.value, }); } } async function submit() { setFormData({ ...formData, dirty: false, }); if ( !formData.address || !formData.value || Number(formData.value) <= 0 ) { return; } // Event("Category", "Action", "Label") // Track number of BCHA send transactions and whether users // are sending BCHA or USD Event('Send.js', 'Send', selectedCurrency); setLoading(true); const { address, value } = formData; // Get the param-free address let cleanAddress = address.split('?')[0]; // Ensure address has bitcoincash: prefix and checksum cleanAddress = toLegacy(cleanAddress); let hasValidCashPrefix; try { hasValidCashPrefix = cleanAddress.startsWith( currency.legacyPrefix + ':', ); } catch (err) { hasValidCashPrefix = false; console.log(`toLegacy() returned an error:`, cleanAddress); } if (!hasValidCashPrefix) { // set loading to false and set address validation to false // Now that the no-prefix case is handled, this happens when user tries to send // BCHA to an SLPA address setLoading(false); setSendBchAddressError( `Destination is not a valid ${currency.ticker} address`, ); return; } // Calculate the amount in BCH let bchValue = value; - if (selectedCurrency === 'USD') { + if (selectedCurrency !== 'XEC') { bchValue = fiatToCrypto(value, fiatPrice); } try { const link = await sendBch( BCH, wallet, slpBalancesAndUtxos.nonSlpUtxos, filledAddress || cleanAddress, bchValue, currency.defaultFee, callbackTxId, ); notification.success({ message: 'Success', description: ( Transaction successful. Click or tap here for more details ), duration: 5, }); } catch (e) { // Set loading to false here as well, as balance may not change depending on where error occured in try loop setLoading(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else if ( e.error && e.error.includes( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 50] (code 64)', ) ) { message = `The ${currency.ticker} you are trying to send has too many unconfirmed ancestors to send (limit 50). Sending will be possible after a block confirmation. Try again in about 10 minutes.`; } else { message = e.message || e.error || JSON.stringify(e); } notification.error({ message: 'Error', description: message, duration: 5, }); console.error(e); } } const handleAddressChange = e => { const { value, name } = e.target; let error = false; let addressString = value; // parse address const addressInfo = parseAddress(BCH, addressString); /* Model addressInfo = { address: '', isValid: false, queryString: '', amount: null, }; */ const { address, isValid, queryString, amount } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); // Is this valid address? if (!isValid) { error = `Invalid ${currency.ticker} address`; // If valid address but token format if (isValidTokenPrefix(address)) { error = `Token addresses are not supported for ${currency.ticker} sends`; } } setSendBchAddressError(error); // Set amount if it's in the query string if (amount !== null) { // Set currency to BCHA setSelectedCurrency(currency.ticker); // Use this object to mimic user input and get validation for the value let amountObj = { target: { name: 'value', value: amount, }, }; handleBchAmountChange(amountObj); setFormData({ ...formData, value: amount, }); } // Set address field to user input setFormData(p => ({ ...p, [name]: value, })); }; const handleSelectedCurrencyChange = e => { setSelectedCurrency(e); // Clear input field to prevent accidentally sending 1 BCH instead of 1 USD setFormData(p => ({ ...p, value: '', })); }; const handleBchAmountChange = e => { const { value, name } = e.target; let bchValue = value; const error = shouldRejectAmountInput( bchValue, selectedCurrency, fiatPrice, balances.totalBalance, ); setSendBchAmountError(error); setFormData(p => ({ ...p, [name]: value, })); }; const onMax = async () => { // Clear amt error setSendBchAmountError(false); // Set currency to BCH setSelectedCurrency(currency.ticker); try { const txFeeSats = calcFee(BCH, slpBalancesAndUtxos.nonSlpUtxos); const txFeeBch = txFeeSats / 10 ** currency.cashDecimals; let value = balances.totalBalance - txFeeBch >= 0 ? (balances.totalBalance - txFeeBch).toFixed( currency.cashDecimals, ) : 0; setFormData({ ...formData, value, }); } catch (err) { console.log(`Error in onMax:`); console.log(err); message.error( 'Unable to calculate the max value due to network errors', ); } }; // Display price in USD below input field for send amount, if it can be calculated let fiatPriceString = ''; if (fiatPrice !== null && !isNaN(formData.value)) { if (selectedCurrency === currency.ticker) { - fiatPriceString = `$ ${(fiatPrice * Number(formData.value)).toFixed( - 2, - )} USD`; + fiatPriceString = `${ + cashtabSettings + ? `${ + currency.fiatCurrencies[cashtabSettings.fiatCurrency] + .symbol + } ` + : '$ ' + } ${(fiatPrice * Number(formData.value)).toFixed(2)} ${ + cashtabSettings && cashtabSettings.fiatCurrency + ? cashtabSettings.fiatCurrency.toUpperCase() + : 'USD' + }`; } else { fiatPriceString = `${ formData.value ? fiatToCrypto(formData.value, fiatPrice) : '0' } ${currency.ticker}`; } } - const priceApiError = fiatPrice === null && selectedCurrency === 'USD'; + const priceApiError = fiatPrice === null && selectedCurrency !== 'XEC'; return ( <>

Are you sure you want to send {formData.value}{' '} {currency.ticker} to {formData.address}?

{!balances.totalBalance ? ( You currently have 0 {currency.ticker}
Deposit some funds to use this feature
) : ( <> {formatBalance(balances.totalBalance)} {currency.ticker} {fiatPrice !== null && ( - ${(balances.totalBalance * fiatPrice).toFixed(2)}{' '} - USD + {cashtabSettings + ? `${ + currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].symbol + } ` + : '$ '} + {(balances.totalBalance * fiatPrice).toFixed(2)}{' '} + {cashtabSettings + ? `${currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].slug.toUpperCase()} ` + : 'USD'} )} )}
handleAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ disabled: Boolean(filledAddress), placeholder: `${currency.ticker} Address`, name: 'address', onChange: e => handleAddressChange(e), required: true, value: filledAddress || formData.address, }} > handleBchAmountChange(e), required: true, value: formData.value, }} selectProps={{ value: selectedCurrency, disabled: queryStringText !== null, onChange: e => handleSelectedCurrencyChange(e), }} > {priceApiError && ( Error fetching fiat price. Setting send by USD disabled )} {fiatPriceString !== '' && '='}{' '} {fiatPriceString}
{!balances.totalBalance || apiError || sendBchAmountError || sendBchAddressError ? ( Send ) : ( <> {txInfoFromUrl ? ( showModal()} > Send ) : ( submit()} > Send )} )}
{queryStringText && ( )} {apiError && ( <>

An error occured on our end. Reconnecting...

)}
); }; export default SendBCH; 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 9f419d317..34694ae11 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,2127 +1,2127 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [
0.06047469 XEC
,
- $ + $ NaN USD
,
XEC
max
= - $ NaN USD + $ NaN USD
, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [
0.06047469 XEC
,
- $ + $ NaN USD
,
XEC
max
= - $ NaN USD + $ NaN USD
, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
0.06047469 XEC
,
- $ + $ NaN USD
,
XEC
max
= - $ NaN USD + $ NaN USD
, ] `; exports[`Wallet with BCH balances and tokens and state field, but no params in state 1`] = ` Array [
0.06047469 XEC
,
- $ + $ NaN USD
,
XEC
max
= - $ NaN USD + $ NaN USD
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max
= - $ NaN USD + $ NaN USD
, ] `; exports[`Without wallet defined 1`] = ` Array [
You currently have 0 XEC
Deposit some funds to use this feature
,
XEC
max
= - $ NaN USD + $ NaN USD
, ] `; diff --git a/web/cashtab/src/components/Tokens/Tokens.js b/web/cashtab/src/components/Tokens/Tokens.js index 5306d86ff..4d224aa11 100644 --- a/web/cashtab/src/components/Tokens/Tokens.js +++ b/web/cashtab/src/components/Tokens/Tokens.js @@ -1,144 +1,159 @@ import React from 'react'; import { LoadingOutlined } from '@ant-design/icons'; import { CashLoader } from '@components/Common/CustomIcons'; import { WalletContext } from '@utils/context'; import { formatBalance, isValidStoredWallet, fromSmallestDenomination, } from '@utils/cashMethods'; import CreateTokenForm from '@components/Tokens/CreateTokenForm'; import { currency } from '@components/Common/Ticker.js'; import TokenList from '@components/Wallet/TokenList'; import useBCH from '@hooks/useBCH'; import { LoadingCtn, BalanceHeader, BalanceHeaderFiat, ZeroBalanceHeader, AlertMsg, } from '@components/Common/Atoms'; const Tokens = ({ jestBCH }) => { /* Dev note This is the first new page created after the wallet migration to include state in storage As such, it will only load this type of wallet If any user is still migrating at this point, this page will display a loading spinner until their wallet has updated (ETA within 10 seconds) Going forward, this approach will be the model for Wallet, Send, and SendToken, as the legacy wallet state parameters not stored in the wallet object are deprecated */ - const { loading, wallet, apiError, fiatPrice } = React.useContext( - WalletContext, - ); + const { + loading, + wallet, + apiError, + fiatPrice, + cashtabSettings, + } = React.useContext(WalletContext); // If wallet is unmigrated, do not show page until it has migrated // An invalid wallet will be validated/populated after the next API call, ETA 10s let validWallet = isValidStoredWallet(wallet); // Get wallet state variables let balances, tokens; if (validWallet) { balances = wallet.state.balances; tokens = wallet.state.tokens; } const { getBCH, getRestUrl, createToken } = useBCH(); // Support using locally installed bchjs for unit tests const BCH = jestBCH ? jestBCH : getBCH(); return ( <> {loading || !validWallet ? ( ) : ( <> {!balances.totalBalance ? ( <> You need some {currency.ticker} in your wallet to create tokens. 0 {currency.ticker} ) : ( <> {formatBalance(balances.totalBalance)}{' '} {currency.ticker} {fiatPrice !== null && !isNaN(balances.totalBalance) && ( - $ + {cashtabSettings + ? `${ + currency.fiatCurrencies[ + cashtabSettings + .fiatCurrency + ].symbol + } ` + : '$ '} {( balances.totalBalance * fiatPrice ).toFixed(2)}{' '} - USD + {cashtabSettings + ? `${currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].slug.toUpperCase()} ` + : 'USD'} )} )} {apiError && ( <>

An error occurred on our end.

Re-establishing connection...

)} {balances.totalBalanceInSatoshis < currency.dustSats && ( You need at least{' '} {fromSmallestDenomination( currency.dustSats, ).toString()}{' '} {currency.ticker} ($ {( fromSmallestDenomination( currency.dustSats, ).toString() * fiatPrice ).toFixed(4)}{' '} USD) to create a token )} {tokens && tokens.length > 0 ? ( <> ) : ( <>No {currency.tokenTicker} tokens in this wallet )} )} ); }; export default Tokens; diff --git a/web/cashtab/src/components/Wallet/Tx.js b/web/cashtab/src/components/Wallet/Tx.js index d18eb91d9..8ebd369df 100644 --- a/web/cashtab/src/components/Wallet/Tx.js +++ b/web/cashtab/src/components/Wallet/Tx.js @@ -1,315 +1,327 @@ import React from 'react'; import styled from 'styled-components'; import { ArrowUpOutlined, ArrowDownOutlined, ExperimentOutlined, } from '@ant-design/icons'; import { currency } from '@components/Common/Ticker'; import makeBlockie from 'ethereum-blockies-base64'; import { Img } from 'react-image'; import { formatBalance, fromLegacyDecimals } from '@utils/cashMethods'; const SentTx = styled(ArrowUpOutlined)` color: ${props => props.theme.secondary} !important; `; const ReceivedTx = styled(ArrowDownOutlined)` color: ${props => props.theme.primary} !important; `; const GenesisTx = styled(ExperimentOutlined)` color: ${props => props.theme.primary} !important; `; const DateType = styled.div` text-align: left; padding: 12px; @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const SentLabel = styled.span` font-weight: bold; color: ${props => props.theme.secondary} !important; `; const ReceivedLabel = styled.span` font-weight: bold; color: ${props => props.theme.primary} !important; `; const TxIcon = styled.div` svg { width: 32px; height: 32px; } height: 32px; width: 32px; @media screen and (max-width: 500px) { svg { width: 24px; height: 24px; } height: 24px; width: 24px; } `; const TxInfo = styled.div` padding: 12px; font-size: 1rem; text-align: right; color: ${props => props.outgoing ? props.theme.secondary : props.theme.primary}; @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const TxFiatPrice = styled.span` font-size: 0.8rem; `; const TokenInfo = styled.div` display: grid; grid-template-rows: 50%; grid-template-columns: 24px auto; padding: 12px; font-size: 1rem; color: ${props => props.outgoing ? props.theme.secondary : props.theme.primary}; @media screen and (max-width: 500px) { font-size: 0.8rem; grid-template-columns: 16px auto; } `; const TxTokenIcon = styled.div` img { height: 24px; width: 24px; } @media screen and (max-width: 500px) { img { height: 16px; width: 16px; } } grid-column-start: 1; grid-column-end: span 1; grid-row-start: 1; grid-row-end: span 2; align-self: center; `; const TokenTxAmt = styled.div` padding-left: 12px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TokenName = styled.div` padding-left: 12px; font-size: 0.8rem; @media screen and (max-width: 500px) { font-size: 0.6rem; } text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TxWrapper = styled.div` display: grid; grid-template-columns: 36px 30% 50%; justify-content: space-between; align-items: center; padding: 15px 25px; border-radius: 3px; background: ${props => props.theme.tokenListItem.background}; margin-bottom: 3px; box-shadow: ${props => props.theme.tokenListItem.boxShadow}; border: 1px solid ${props => props.theme.tokenListItem.border}; :hover { border-color: ${props => props.theme.primary}; } @media screen and (max-width: 500px) { grid-template-columns: 24px 30% 50%; padding: 12px 12px; } `; -const Tx = ({ data, fiatPrice }) => { +const Tx = ({ data, fiatPrice, fiatCurrency }) => { const txDate = typeof data.blocktime === 'undefined' ? new Date().toLocaleDateString() : new Date(data.blocktime * 1000).toLocaleDateString(); return ( {data.outgoingTx ? ( <> {data.tokenTx && data.tokenInfo.transactionType === 'GENESIS' ? ( ) : ( )} ) : ( )} {data.outgoingTx ? ( <> {data.tokenTx && data.tokenInfo.transactionType === 'GENESIS' ? ( Genesis ) : ( Sent )} ) : ( Received )}
{txDate}
{data.tokenTx ? ( {data.tokenTx && data.tokenInfo ? ( <> {currency.tokenIconsUrl !== '' ? ( {`identicon } /> ) : ( {`identicon )} {data.outgoingTx ? ( <> {data.tokenInfo.transactionType === 'GENESIS' ? ( <> +{' '} {data.tokenInfo.qtyReceived.toString()}   {data.tokenInfo.tokenTicker} {data.tokenInfo.tokenName} ) : ( <> -{' '} {data.tokenInfo.qtySent.toString()}   {data.tokenInfo.tokenTicker} {data.tokenInfo.tokenName} )} ) : ( <> +{' '} {data.tokenInfo.qtyReceived.toString()}  {data.tokenInfo.tokenTicker} {data.tokenInfo.tokenName} )} ) : ( Token Tx )} ) : ( <> {data.outgoingTx ? ( <> -{' '} {formatBalance( fromLegacyDecimals(data.amountSent), )} {currency.ticker}
- {fiatPrice !== null && - !isNaN(data.amountSent) && ( - - - $ - {( - fromLegacyDecimals( - data.amountSent, - ) * fiatPrice - ).toFixed(2)}{' '} - USD - - )} + {fiatPrice !== null && !isNaN(data.amountSent) && ( + + -{' '} + { + currency.fiatCurrencies[ + fiatCurrency + ].symbol + } + {( + fromLegacyDecimals( + data.amountSent, + ) * fiatPrice + ).toFixed(2)}{' '} + {currency.fiatCurrencies.fiatCurrency} + + )} ) : ( <> +{' '} {formatBalance( fromLegacyDecimals(data.amountReceived), )} {currency.ticker}
{fiatPrice !== null && !isNaN(data.amountReceived) && ( - + $ + +{' '} + { + currency.fiatCurrencies[ + fiatCurrency + ].symbol + } {( fromLegacyDecimals( data.amountReceived, ) * fiatPrice ).toFixed(2)}{' '} - USD + { + currency.fiatCurrencies + .fiatCurrency + } )} )}
)}
); }; export default Tx; diff --git a/web/cashtab/src/components/Wallet/TxHistory.js b/web/cashtab/src/components/Wallet/TxHistory.js index 972e6a61b..4321cbf53 100644 --- a/web/cashtab/src/components/Wallet/TxHistory.js +++ b/web/cashtab/src/components/Wallet/TxHistory.js @@ -1,24 +1,28 @@ import React from 'react'; import styled from 'styled-components'; import Tx from './Tx'; export const TxLink = styled.a``; -const TxHistory = ({ txs, fiatPrice }) => { +const TxHistory = ({ txs, fiatPrice, fiatCurrency }) => { return (
{txs.map(tx => ( - + ))}
); }; export default TxHistory; diff --git a/web/cashtab/src/components/Wallet/Wallet.js b/web/cashtab/src/components/Wallet/Wallet.js index a659a043f..f660d1eb9 100644 --- a/web/cashtab/src/components/Wallet/Wallet.js +++ b/web/cashtab/src/components/Wallet/Wallet.js @@ -1,398 +1,414 @@ import React from 'react'; import styled from 'styled-components'; import { Switch } from 'antd'; import { LinkOutlined, LoadingOutlined } from '@ant-design/icons'; import { WalletContext } from '@utils/context'; import { OnBoarding } from '@components/OnBoarding/OnBoarding'; import { QRCode } from '@components/Common/QRCode'; import { currency } from '@components/Common/Ticker.js'; import { Link } from 'react-router-dom'; import TokenList from './TokenList'; import TxHistory from './TxHistory'; import { CashLoader } from '@components/Common/CustomIcons'; import { formatBalance } from '@utils/cashMethods'; import { LoadingCtn, BalanceHeader, BalanceHeaderFiat, ZeroBalanceHeader, } from '@components/Common/Atoms'; export const Tabs = styled.div` margin: auto; margin-bottom: 12px; display: inline-block; text-align: center; `; export const TabLabel = styled.button` :focus, :active { outline: none; } border: none; background: none; font-size: 20px; cursor: pointer; @media (max-width: 400px) { font-size: 16px; } ${({ active, ...props }) => active && ` color: ${props.theme.primary}; `} `; export const TabLine = styled.div` margin: auto; transition: margin-left 0.5s ease-in-out, width 0.5s 0.1s; height: 4px; border-radius: 5px; background-color: ${props => props.theme.primary}; pointer-events: none; margin-left: 72%; width: 28%; ${({ left, ...props }) => left && ` margin-left: 1% width: 69%; `} `; export const TabPane = styled.div` ${({ active }) => !active && ` display: none; `} `; export const SwitchBtnCtn = styled.div` display: flex; align-items: center; justify-content: center; align-content: space-between; margin-bottom: 15px; .nonactiveBtn { color: ${props => props.theme.wallet.text.secondary}; background: ${props => props.theme.wallet.switch.inactive.background} !important; box-shadow: none !important; } .slpActive { background: ${props => props.theme.wallet.switch.activeToken.background} !important; box-shadow: ${props => props.theme.wallet.switch.activeToken.shadow} !important; } `; export const SwitchBtn = styled.div` font-weight: bold; display: inline-block; cursor: pointer; color: ${props => props.theme.contrast}; font-size: 14px; padding: 6px 0; width: 100px; margin: 0 1px; text-decoration: none; background: ${props => props.theme.primary}; box-shadow: ${props => props.theme.wallet.switch.activeCash.shadow}; user-select: none; :first-child { border-radius: 100px 0 0 100px; } :nth-child(2) { border-radius: 0 100px 100px 0; } `; export const Links = styled(Link)` color: ${props => props.theme.wallet.text.secondary}; width: 100%; font-size: 16px; margin: 10px 0 20px 0; border: 1px solid ${props => props.theme.wallet.text.secondary}; padding: 14px 0; display: inline-block; border-radius: 3px; transition: all 200ms ease-in-out; svg { fill: ${props => props.theme.wallet.text.secondary}; } :hover { color: ${props => props.theme.primary}; border-color: ${props => props.theme.primary}; svg { fill: ${props => props.theme.primary}; } } @media (max-width: 768px) { padding: 10px 0; font-size: 14px; } `; export const ExternalLink = styled.a` color: ${props => props.theme.wallet.text.secondary}; width: 100%; font-size: 16px; margin: 0 0 20px 0; border: 1px solid ${props => props.theme.wallet.text.secondary}; padding: 14px 0; display: inline-block; border-radius: 3px; transition: all 200ms ease-in-out; svg { fill: ${props => props.theme.wallet.text.secondary}; transition: all 200ms ease-in-out; } :hover { color: ${props => props.theme.primary}; border-color: ${props => props.theme.primary}; svg { fill: ${props => props.theme.primary}; } } @media (max-width: 768px) { padding: 10px 0; font-size: 14px; } `; export const AddrSwitchContainer = styled.div` text-align: center; padding: 6px 0 12px 0; `; export const AddrPrefixSwitch = styled(Switch)``; export const AddrPrefixLabel = styled.span` color: ${props => props.theme.wallet.text.primary} margin-right: 4px; `; const WalletInfo = () => { const ContextValue = React.useContext(WalletContext); - const { wallet, fiatPrice, apiError } = ContextValue; + const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; let balances; let parsedTxHistory; let tokens; // use parameters from wallet.state object and not legacy separate parameters, if they are in state // handle edge case of user with old wallet who has not opened latest Cashtab version yet // If the wallet object from ContextValue has a `state key`, then check which keys are in the wallet object // Else set it as blank const paramsInWalletState = wallet.state ? Object.keys(wallet.state) : []; // If wallet.state includes balances and parsedTxHistory params, use these // These are saved in indexedDb in the latest version of the app, hence accessible more quickly if ( paramsInWalletState.includes('balances') && paramsInWalletState.includes('parsedTxHistory') && paramsInWalletState.includes('tokens') ) { balances = wallet.state.balances; parsedTxHistory = wallet.state.parsedTxHistory; tokens = wallet.state.tokens; } else { // If balances and parsedTxHistory are not in the wallet.state object, load them from Context // This is how the app used to work balances = ContextValue.balances; parsedTxHistory = ContextValue.parsedTxHistory; tokens = ContextValue.tokens; } const [address, setAddress] = React.useState('cashAddress'); const [addressPrefix, setAddressPrefix] = React.useState('eCash'); const [activeTab, setActiveTab] = React.useState('txHistory'); const hasHistory = parsedTxHistory && parsedTxHistory.length > 0; const handleChangeAddress = () => { setAddress(address === 'cashAddress' ? 'slpAddress' : 'cashAddress'); }; const onAddressPrefixChange = () => { setAddressPrefix(addressPrefix === 'eCash' ? 'bitcoincash' : 'eCash'); }; return ( <> {!balances.totalBalance && !apiError && !hasHistory ? ( <> 🎉 Congratulations on your new wallet!{' '} 🎉
Start using the wallet immediately to receive{' '} {currency.ticker} payments, or load it up with{' '} {currency.ticker} to send to others
0 {currency.ticker} ) : ( <> {formatBalance(balances.totalBalance)} {currency.ticker} {fiatPrice !== null && !isNaN(balances.totalBalance) && ( - ${(balances.totalBalance * fiatPrice).toFixed(2)}{' '} - USD + {cashtabSettings + ? `${ + currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].symbol + } ` + : '$ '} + {(balances.totalBalance * fiatPrice).toFixed(2)}{' '} + {cashtabSettings + ? `${currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].slug.toUpperCase()} ` + : 'USD'} )} )} {apiError && ( <>

An error occurred on our end.

Re-establishing connection...

)} {wallet && ((wallet.Path245 && wallet.Path145) || wallet.Path1899) && ( <> {wallet.Path1899 ? ( <> Address Format: ) : ( <> )} )} handleChangeAddress()} className={ address !== 'cashAddress' ? 'nonactiveBtn' : null } > {currency.ticker} handleChangeAddress()} className={ address === 'cashAddress' ? 'nonactiveBtn' : 'slpActive' } > {currency.tokenTicker} {hasHistory && parsedTxHistory && ( <> setActiveTab('txHistory')} > Transaction History setActiveTab('tokens')} > Tokens {tokens && tokens.length > 0 ? ( ) : (

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

)}
)} ); }; const Wallet = () => { const ContextValue = React.useContext(WalletContext); const { wallet, loading } = ContextValue; return ( <> {loading ? ( ) : ( <>{wallet.Path1899 ? : } )} ); }; export default Wallet; diff --git a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap index a29fb9ccf..cab22a3f8 100644 --- a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap +++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap @@ -1,794 +1,794 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [
0.06047469 XEC
,
- $ + $ NaN USD
,
Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
,
Address Format:
,
XEC
eToken
, ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [
0.06047469 XEC
,
- $ + $ NaN USD
,
Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
,
Address Format:
,
XEC
eToken
, ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [
0.06047469 XEC
,
- $ + $ NaN USD
,
Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
,
Address Format:
,
XEC
eToken
, ] `; exports[`Wallet with BCH balances and tokens and state field, but no params in state 1`] = ` Array [
0.06047469 XEC
,
- $ + $ NaN USD
,
Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
,
Address Format:
,
XEC
eToken
, ] `; exports[`Wallet without BCH balance 1`] = ` Array [
🎉 Congratulations on your new wallet! 🎉
Start using the wallet immediately to receive XEC payments, or load it up with XEC to send to others
,
0 XEC
,
Copied
ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
qzagy47m vh6qxkvcn3acjnz73rkhkc6y7c cxkrr6zd
,
Address Format:
,
XEC
eToken
, ] `; exports[`Without wallet defined 1`] = ` Array [

Welcome to Cashtab!

,

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

Want to learn more? Check out the Cashtab documentation.

, , , ] `; diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js index c784c9796..cfb5fbfe1 100644 --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -1,1343 +1,1441 @@ /* eslint-disable react-hooks/exhaustive-deps */ import React, { useState, useEffect } from 'react'; import Paragraph from 'antd/lib/typography/Paragraph'; import { notification } from 'antd'; import useAsyncTimeout from '@hooks/useAsyncTimeout'; import usePrevious from '@hooks/usePrevious'; import useBCH from '@hooks/useBCH'; import BigNumber from 'bignumber.js'; import { fromSmallestDenomination, loadStoredWallet, isValidStoredWallet, } from '@utils/cashMethods'; +import { isValidCashtabSettings } from '@utils/validation'; import localforage from 'localforage'; import { currency } from '@components/Common/Ticker'; import isEmpty from 'lodash.isempty'; import isEqual from 'lodash.isequal'; const useWallet = () => { const [wallet, setWallet] = useState(false); + const [cashtabSettings, setCashtabSettings] = useState(false); const [fiatPrice, setFiatPrice] = useState(null); const [ws, setWs] = useState(null); const [apiError, setApiError] = useState(false); + const [checkFiatInterval, setCheckFiatInterval] = useState(null); const [walletState, setWalletState] = useState({ balances: {}, hydratedUtxoDetails: {}, tokens: [], slpBalancesAndUtxos: {}, parsedTxHistory: [], utxos: [], }); 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 [utxos, setUtxos] = useState(null); const { balances, tokens, slpBalancesAndUtxos, parsedTxHistory, } = walletState; const previousBalances = usePrevious(balances); const previousTokens = usePrevious(tokens); const previousWallet = usePrevious(wallet); const previousUtxos = usePrevious(utxos); // If you catch API errors, call this function const tryNextAPI = () => { let currentApiIndex = apiIndex; // How many APIs do you have? const apiString = process.env.REACT_APP_BCHA_APIS; const apiArray = apiString.split(','); console.log(`You have ${apiArray.length} APIs to choose from`); console.log(`Current selection: ${apiIndex}`); // If only one, exit if (apiArray.length === 0) { console.log( `There are no backup APIs, you are stuck with this error`, ); return; } else if (currentApiIndex < apiArray.length - 1) { currentApiIndex += 1; console.log( `Incrementing API index from ${apiIndex} to ${currentApiIndex}`, ); } else { // Otherwise use the first option again console.log(`Retrying first API index`); currentApiIndex = 0; } //return setApiIndex(currentApiIndex); console.log(`Setting Api Index to ${currentApiIndex}`); setApiIndex(currentApiIndex); return setBCH(getBCH(currentApiIndex)); // If you have more than one, use the next one // If you are at the "end" of the array, use the first one }; const normalizeSlpBalancesAndUtxos = (slpBalancesAndUtxos, wallet) => { const Accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; slpBalancesAndUtxos.nonSlpUtxos.forEach(utxo => { const derivatedAccount = Accounts.find( account => account.cashAddress === utxo.address, ); utxo.wif = derivatedAccount.fundingWif; }); return slpBalancesAndUtxos; }; const normalizeBalance = slpBalancesAndUtxos => { const totalBalanceInSatoshis = slpBalancesAndUtxos.nonSlpUtxos.reduce( (previousBalance, utxo) => previousBalance + utxo.value, 0, ); return { totalBalanceInSatoshis, totalBalance: fromSmallestDenomination(totalBalanceInSatoshis), }; }; const deriveAccount = async (BCH, { masterHDNode, path }) => { const node = BCH.HDNode.derivePath(masterHDNode, path); const cashAddress = BCH.HDNode.toCashAddress(node); const slpAddress = BCH.SLP.Address.toSLPAddress(cashAddress); return { 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); return setWalletState(liveWalletState); } // 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, setWalletState }) => { //console.log(`tick()`); //console.time("update"); try { if (!wallet) { return; } const cashAddresses = [ wallet.Path245.cashAddress, wallet.Path145.cashAddress, wallet.Path1899.cashAddress, ]; const utxos = await getUtxos(BCH, cashAddresses); // If an error is returned or utxos from only 1 address are returned if (!utxos || isEmpty(utxos) || utxos.error || utxos.length < 2) { // Throw error here to prevent more attempted api calls // as you are likely already at rate limits throw new Error('Error fetching utxos'); } setUtxos(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 walletState has not changed and does not need to be updated //console.timeEnd("update"); return; } const hydratedUtxoDetails = await getHydratedUtxoDetails( BCH, utxos, ); const slpBalancesAndUtxos = await getSlpBalancesAndUtxos( hydratedUtxoDetails, ); const txHistory = await getTxHistory(BCH, cashAddresses); const parsedTxHistory = await getTxData(BCH, txHistory); const parsedWithTokens = await addTokenTxData(BCH, parsedTxHistory); console.log(`slpBalancesAndUtxos`, slpBalancesAndUtxos); if (typeof slpBalancesAndUtxos === 'undefined') { console.log(`slpBalancesAndUtxos is undefined`); throw new Error('slpBalancesAndUtxos is undefined'); } const { tokens } = slpBalancesAndUtxos; const newState = { balances: {}, tokens: [], slpBalancesAndUtxos: [], }; newState.slpBalancesAndUtxos = normalizeSlpBalancesAndUtxos( slpBalancesAndUtxos, wallet, ); newState.balances = normalizeBalance(slpBalancesAndUtxos); newState.tokens = tokens; newState.parsedTxHistory = parsedWithTokens; newState.utxos = utxos; newState.hydratedUtxoDetails = hydratedUtxoDetails; setWalletState(newState); // Set wallet with new state field // Note: now that wallet carries state, maintaining a separate walletState object is redundant // TODO clear up in future diff wallet.state = wallet.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, setWalletState})`); console.log(error); // Set this in state so that transactions are disabled until the issue is resolved setApiError(true); //console.timeEnd("update"); // Try another endpoint console.log(`Trying next API...`); tryNextAPI(); } //console.timeEnd("update"); }; const getActiveWalletFromLocalForage = async () => { let wallet; try { wallet = await localforage.getItem('wallet'); } catch (err) { console.log(`Error in getActiveWalletFromLocalForage`, err); wallet = null; } return wallet; }; /* const getSavedWalletsFromLocalForage = async () => { let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log(`Error in getSavedWalletsFromLocalForage`, err); savedWallets = null; } return savedWallets; }; */ const getWallet = async () => { let wallet; let existingWallet; try { existingWallet = await getActiveWalletFromLocalForage(); // existing wallet will be // 1 - the 'wallet' value from localForage, if it exists // 2 - false if it does not exist in localForage // 3 - null if error // If the wallet does not have Path1899, add it if (existingWallet && !existingWallet.Path1899) { console.log(`Wallet does not have Path1899`); existingWallet = await migrateLegacyWallet(BCH, existingWallet); } // 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 Path1899 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/1899'/0'/0/0", }); wallet.Path1899 = Path1899; try { await localforage.setItem('wallet', wallet); } catch (err) { console.log( `Error setting wallet to wallet indexedDb in migrateLegacyWallet()`, ); console.log(err); } return wallet; }; const writeWalletState = async (wallet, newState) => { // Add new state as an object on the active wallet wallet.state = newState; try { await localforage.setItem('wallet', wallet); } catch (err) { console.log(`Error in writeWalletState()`); console.log(err); } }; const getWalletDetails = async wallet => { if (!wallet) { return false; } // Since this info is in localforage now, only get the var const NETWORK = process.env.REACT_APP_NETWORK; const mnemonic = wallet.mnemonic; const rootSeedBuffer = await BCH.Mnemonic.toSeed(mnemonic); let masterHDNode; if (NETWORK === `mainnet`) { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer); } else { masterHDNode = BCH.HDNode.fromSeed(rootSeedBuffer, 'testnet'); } const Path245 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/245'/0'/0/0", }); const Path145 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/145'/0'/0/0", }); const Path1899 = await deriveAccount(BCH, { masterHDNode, path: "m/44'/1899'/0'/0/0", }); let name = Path1899.cashAddress.slice(12, 17); // Only set the name if it does not currently exist if (wallet && wallet.name) { name = wallet.name; } return { mnemonic: wallet.mnemonic, name, Path245, Path145, Path1899, }; }; const getSavedWallets = async activeWallet => { let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); if (savedWallets === null) { savedWallets = []; } } catch (err) { console.log(`Error in getSavedWallets`); console.log(err); savedWallets = []; } // Even though the active wallet is still stored in savedWallets, don't return it in this function for (let i = 0; i < savedWallets.length; i += 1) { if ( typeof activeWallet !== 'undefined' && activeWallet.name && savedWallets[i].name === activeWallet.name ) { savedWallets.splice(i, 1); } } return savedWallets; }; const activateWallet = async walletToActivate => { /* If the user is migrating from old version to this version, make sure to save the activeWallet 1 - check savedWallets for the previously active wallet 2 - If not there, add it */ let currentlyActiveWallet; try { currentlyActiveWallet = await localforage.getItem('wallet'); } catch (err) { console.log( `Error in localforage.getItem("wallet") in activateWallet()`, ); return false; } // Get savedwallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in localforage.getItem("savedWallets") in activateWallet()`, ); return false; } /* When a legacy user runs cashtabapp.com/, their active wallet will be migrated to Path1899 by the getWallet function 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 */ // Need to handle a similar situation with state // If you find the activeWallet in savedWallets but without state, resave active wallet with state // Note you do not have the Case 2 described above here, as wallet state is added in the update() function of useWallet.js // Also note, since state can be expected to change frequently (unlike path deriv), you will likely save it every time you activate a new wallet // Check savedWallets for currentlyActiveWallet let walletInSavedWallets = false; for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === currentlyActiveWallet.name) { walletInSavedWallets = true; // Check savedWallets for unmigrated currentlyActiveWallet if (!savedWallets[i].Path1899) { // Case 1, described above savedWallets[i].Path1899 = currentlyActiveWallet.Path1899; } /* Update wallet state Note, this makes previous `walletUnmigrated` variable redundant savedWallets[i] should always be updated, since wallet state can be expected to change most of the time */ savedWallets[i].state = currentlyActiveWallet.state; } } // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets") in activateWallet() for unmigrated wallet`, ); } if (!walletInSavedWallets) { console.log(`Wallet is not in saved Wallets, adding`); savedWallets.push(currentlyActiveWallet); // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets") in activateWallet()`, ); } } // If wallet does not have Path1899, add it if (!walletToActivate.Path1899) { // Case 2, described above console.log(`Case 2: Wallet to activate does not have Path1899`); console.log( `Wallet to activate from SavedWallets does not have Path1899`, ); console.log(`walletToActivate`, walletToActivate); walletToActivate = await migrateLegacyWallet(BCH, walletToActivate); } else { // Otherwise activate it as normal // Now that we have verified the last wallet was saved, we can activate the new wallet try { await localforage.setItem('wallet', walletToActivate); } catch (err) { console.log( `Error in localforage.setItem("wallet", walletToActivate) in activateWallet()`, ); return false; } } // Make sure stored wallet is in correct format to be used as live wallet if (isValidStoredWallet(walletToActivate)) { // Convert all the token balance figures to big numbers const liveWalletState = loadStoredWallet(walletToActivate.state); walletToActivate.state = liveWalletState; } return walletToActivate; }; const renameWallet = async (oldName, newName) => { // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in renameWallet`, ); console.log(err); return false; } // Verify that no existing wallet has this name for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === newName) { // return an error return false; } } // change name of desired wallet for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === oldName) { // Replace the name of this entry with the new name savedWallets[i].name = newName; } } // resave savedWallets try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", savedWallets) in renameWallet()`, ); return false; } return true; }; const deleteWallet = async walletToBeDeleted => { // delete a wallet // returns true if wallet is successfully deleted // otherwise returns false // Load savedWallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); } catch (err) { console.log( `Error in await localforage.getItem("savedWallets") in deleteWallet`, ); console.log(err); return false; } // Iterate over to find the wallet to be deleted // Verify that no existing wallet has this name let walletFoundAndRemoved = false; for (let i = 0; i < savedWallets.length; i += 1) { if (savedWallets[i].name === walletToBeDeleted.name) { // Verify it has the same mnemonic too, that's a better UUID if (savedWallets[i].mnemonic === walletToBeDeleted.mnemonic) { // Delete it savedWallets.splice(i, 1); walletFoundAndRemoved = true; } } } // If you don't find the wallet, return false if (!walletFoundAndRemoved) { return false; } // Resave savedWallets less the deleted wallet try { // Set walletName as the active wallet await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", savedWallets) in deleteWallet()`, ); return false; } return true; }; const addNewSavedWallet = async importMnemonic => { // Add a new wallet to savedWallets from importMnemonic or just new wallet const lang = 'english'; // create 128 bit BIP39 mnemonic const Bip39128BitMnemonic = importMnemonic ? importMnemonic : BCH.Mnemonic.generate(128, BCH.Mnemonic.wordLists()[lang]); const newSavedWallet = await getWalletDetails({ mnemonic: Bip39128BitMnemonic.toString(), }); // Get saved wallets let savedWallets; try { savedWallets = await localforage.getItem('savedWallets'); // If this doesn't exist yet, savedWallets === null if (savedWallets === null) { savedWallets = []; } } catch (err) { console.log( `Error in savedWallets = await localforage.getItem("savedWallets") in addNewSavedWallet()`, ); console.log(err); console.log(`savedWallets in error state`, savedWallets); } // If this wallet is from an imported mnemonic, make sure it does not already exist in savedWallets if (importMnemonic) { for (let i = 0; i < savedWallets.length; i += 1) { // Check for condition "importing new wallet that is already in savedWallets" if (savedWallets[i].mnemonic === importMnemonic) { // set this as the active wallet to keep name history console.log( `Error: this wallet already exists in savedWallets`, ); console.log(`Wallet not being added.`); return false; } } } // add newSavedWallet savedWallets.push(newSavedWallet); // update savedWallets try { await localforage.setItem('savedWallets', savedWallets); } catch (err) { console.log( `Error in localforage.setItem("savedWallets", activeWallet) called in createWallet with ${importMnemonic}`, ); console.log(`savedWallets`, savedWallets); console.log(err); } return true; }; const createWallet = async importMnemonic => { const lang = 'english'; // create 128 bit BIP39 mnemonic const Bip39128BitMnemonic = importMnemonic ? importMnemonic : BCH.Mnemonic.generate(128, BCH.Mnemonic.wordLists()[lang]); const wallet = await getWalletDetails({ mnemonic: Bip39128BitMnemonic.toString(), }); try { await localforage.setItem('wallet', wallet); } catch (err) { console.log( `Error setting wallet to wallet indexedDb in createWallet()`, ); console.log(err); } // Since this function is only called from OnBoarding.js, also add this to the saved wallet try { await localforage.setItem('savedWallets', [wallet]); } catch (err) { console.log( `Error setting wallet to savedWallets indexedDb in createWallet()`, ); console.log(err); } return wallet; }; const validateMnemonic = ( mnemonic, wordlist = BCH.Mnemonic.wordLists().english, ) => { let mnemonicTestOutput; try { mnemonicTestOutput = BCH.Mnemonic.validate(mnemonic, wordlist); if (mnemonicTestOutput === 'Valid mnemonic') { return true; } else { return false; } } catch (err) { console.log(err); return false; } }; const handleUpdateWallet = async setWallet => { await loadWalletFromStorageOnStartup(setWallet); }; + const loadCashtabSettings = async () => { + // get settings object from localforage + let localSettings; + try { + localSettings = await localforage.getItem('settings'); + // If there is no keyvalue pair in localforage with key 'settings' + if (localSettings === null) { + // Create one with the default settings from Ticker.js + localforage.setItem('settings', currency.defaultSettings); + // Set state to default settings + setCashtabSettings(currency.defaultSettings); + return currency.defaultSettings; + } + } catch (err) { + console.log(`Error getting cashtabSettings`, err); + // TODO If they do not exist, write them + // TODO add function to change them + setCashtabSettings(currency.defaultSettings); + return currency.defaultSettings; + } + // If you found an object in localforage at the settings key, make sure it's valid + if (isValidCashtabSettings(localSettings)) { + setCashtabSettings(localSettings); + return localSettings; + } + // if not valid, also set cashtabSettings to default + setCashtabSettings(currency.defaultSettings); + return currency.defaultSettings; + }; + + // With different currency selections possible, need unique intervals for price checks + // Must be able to end them and set new ones with new currencies + const initializeFiatPriceApi = async selectedFiatCurrency => { + // Update fiat price and confirm it is set to make sure ap keeps loading state until this is updated + await fetchBchPrice(selectedFiatCurrency); + // Set interval for updating the price with given currency + + const thisFiatInterval = setInterval(function () { + fetchBchPrice(selectedFiatCurrency); + }, 60000); + + // set interval in state + setCheckFiatInterval(thisFiatInterval); + }; + + const clearFiatPriceApi = fiatPriceApi => { + // Clear fiat price check interval of previously selected currency + clearInterval(fiatPriceApi); + }; + + const changeCashtabSettings = async (key, newValue) => { + // Set loading to true as you do not want to display the fiat price of the last currency + // loading = true will lock the UI until the fiat price has updated + setLoading(true); + // Get settings from localforage + let currentSettings; + let newSettings; + try { + currentSettings = await localforage.getItem('settings'); + } catch (err) { + console.log(`Error in changeCashtabSettings`, err); + // Set fiat price to null, which disables fiat sends throughout the app + setFiatPrice(null); + // Unlock the UI + setLoading(false); + return; + } + // Make sure function was called with valid params + if ( + Object.keys(currentSettings).includes(key) && + currency.settingsValidation[key].includes(newValue) + ) { + // Update settings + newSettings = currentSettings; + newSettings[key] = newValue; + } + // Set new settings in state so they are available in context throughout the app + setCashtabSettings(newSettings); + // If this settings change adjusted the fiat currency, update fiat price + if (key === 'fiatCurrency') { + clearFiatPriceApi(checkFiatInterval); + initializeFiatPriceApi(newValue); + } + // Write new settings in localforage + try { + await localforage.setItem('settings', newSettings); + } catch (err) { + console.log( + `Error writing newSettings object to localforage in changeCashtabSettings`, + err, + ); + console.log(`newSettings`, newSettings); + // do nothing. If this happens, the user will see default currency next time they load the app. + } + setLoading(false); + }; + // Parse for incoming BCH transactions // Only notify if websocket is not connected if ( (ws === null || ws.readyState !== 1) && previousBalances && balances && 'totalBalance' in previousBalances && 'totalBalance' in balances && new BigNumber(balances.totalBalance) .minus(previousBalances.totalBalance) .gt(0) ) { notification.success({ message: 'Transaction received', description: ( You received{' '} {Number( balances.totalBalance - previousBalances.totalBalance, ).toFixed(currency.cashDecimals)}{' '} {currency.ticker}! ), duration: 3, }); } // Parse for incoming SLP transactions if ( tokens && tokens[0] && tokens[0].balance && previousTokens && previousTokens[0] && previousTokens[0].balance ) { // If tokens length is greater than previousTokens length, a new token has been received // Note, a user could receive a new token, AND more of existing tokens in between app updates // In this case, the app will only notify about the new token // TODO better handling for all possible cases to cover this // TODO handle with websockets for better response time, less complicated calc if (tokens.length > previousTokens.length) { // Find the new token const tokenIds = tokens.map(({ tokenId }) => tokenId); const previousTokenIds = previousTokens.map( ({ tokenId }) => tokenId, ); //console.log(`tokenIds`, tokenIds); //console.log(`previousTokenIds`, previousTokenIds); // An array with the new token Id const newTokenIdArr = tokenIds.filter( tokenId => !previousTokenIds.includes(tokenId), ); // It's possible that 2 new tokens were received // To do, handle this case const newTokenId = newTokenIdArr[0]; //console.log(newTokenId); // How much of this tokenId did you get? // would be at // Find where the newTokenId is const receivedTokenObjectIndex = tokens.findIndex( x => x.tokenId === newTokenId, ); //console.log(`receivedTokenObjectIndex`, receivedTokenObjectIndex); // Calculate amount received //console.log(`receivedTokenObject:`, tokens[receivedTokenObjectIndex]); const receivedSlpQty = tokens[ receivedTokenObjectIndex ].balance.toString(); const receivedSlpTicker = tokens[receivedTokenObjectIndex].info.tokenTicker; const receivedSlpName = tokens[receivedTokenObjectIndex].info.tokenName; //console.log(`receivedSlpQty`, receivedSlpQty); // Notification if you received SLP if (receivedSlpQty > 0) { notification.success({ message: `${currency.tokenTicker} Transaction received: ${receivedSlpTicker}`, description: ( You received {receivedSlpQty} {receivedSlpName} ), duration: 5, }); } // } 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; notification.success({ message: `SLP Transaction received: ${receivedSlpTicker}`, description: ( You received {receivedSlpQty.toString()}{' '} {receivedSlpName} ), duration: 5, }); } } } } - // Update price every 1 min - useAsyncTimeout(async () => { - fetchBchPrice(); - }, 60000); - // Update wallet every 10s useAsyncTimeout(async () => { const wallet = await getWallet(); update({ wallet, setWalletState, }).finally(() => { setLoading(false); }); }, 10000); const initializeWebsocket = (cashAddress, slpAddress) => { // console.log(`initializeWebsocket(${cashAddress}, ${slpAddress})`); // This function parses 3 cases // 1: edge case, websocket is in state but not properly connected // > Remove it from state and forget about it, fall back to normal notifications // 2: edge-ish case, websocket is in state and connected but user has changed wallet // > Unsubscribe from old addresses and subscribe to new ones // 3: most common: app is opening, creating websocket with existing addresses // If the websocket is already in state but is not properly connected if (ws !== null && ws.readyState !== 1) { // Forget about it and use conventional notifications // Close ws.close(); // Remove from state setWs(null); } // If the websocket is in state and connected else if (ws !== null) { // console.log(`Websocket already in state`); // console.log(`ws,`, ws); // instead of initializing websocket, unsubscribe from old addresses and subscribe to new ones const previousWsCashAddress = previousWallet.Path145.legacyAddress; const previousWsSlpAddress = previousWallet.Path245.legacyAddress; try { // Unsubscribe from previous addresses ws.send( JSON.stringify({ op: 'addr_unsub', addr: previousWsCashAddress, }), ); console.log( `Unsubscribed from BCH address at ${previousWsCashAddress}`, ); ws.send( JSON.stringify({ op: 'addr_unsub', addr: previousWsSlpAddress, }), ); console.log( `Unsubscribed from SLP address at ${previousWsSlpAddress}`, ); // Subscribe to new addresses ws.send( JSON.stringify({ op: 'addr_sub', addr: cashAddress, }), ); console.log(`Subscribed to BCH address at ${cashAddress}`); // Subscribe to SLP address ws.send( JSON.stringify({ op: 'addr_sub', addr: slpAddress, }), ); console.log(`Subscribed to SLP address at ${slpAddress}`); // Reset onmessage; it was previously set with the old addresses // Note this code is exactly identical to lines 431-490 // TODO put in function ws.onmessage = e => { // TODO handle case where receive multiple messages on one incoming transaction //console.log(`ws msg received`); const incomingTx = JSON.parse(e.data); console.log(incomingTx); let bchSatsReceived = 0; // First, check the inputs // If cashAddress or slpAddress are in the inputs, then this is a sent tx and should be ignored for notifications if ( incomingTx && incomingTx.x && incomingTx.x.inputs && incomingTx.x.out ) { const inputs = incomingTx.x.inputs; // Iterate over inputs and see if this transaction was sent by the active wallet for (let i = 0; i < inputs.length; i += 1) { if ( inputs[i].prev_out.addr === cashAddress || inputs[i].prev_out.addr === slpAddress ) { // console.log(`Found a sending tx, not notifying`); // This is a sent transaction and should be ignored by notification handlers return; } } // Iterate over outputs to determine receiving address const outputs = incomingTx.x.out; for (let i = 0; i < outputs.length; i += 1) { if (outputs[i].addr === cashAddress) { // console.log(`BCH transaction received`); bchSatsReceived += outputs[i].value; // handle } if (outputs[i].addr === slpAddress) { console.log(`SLP transaction received`); //handle // you would want to get the slp info using this endpoint: // https://rest.kingbch.com/v3/slp/txDetails/cb39dd04e07e172a37addfcb1d6e167dc52c01867ba21c9bf8b5acf4dd969a3f // But it does not work for unconfirmed txs // Hold off on slp tx notifications for now } } } // parse for receiving address // if received at cashAddress, parse for BCH amount, notify BCH received // if received at slpAddress, parse for token, notify SLP received // if those checks fail, could be from a 'sent' tx, ignore // Note, when you send an SLP tx, you get SLP change to SLP address and BCH change to BCH address // Also note, when you send an SLP tx, you often have inputs from both BCH and SLP addresses // This causes a sent SLP tx to register 4 times from the websocket // Best way to ignore this is to ignore any incoming utx.x with BCH or SLP address in the inputs // Notification for received BCHA if (bchSatsReceived > 0) { notification.success({ message: 'Transaction received', description: ( You received {bchSatsReceived / 1e8}{' '} {currency.ticker}! ), duration: 3, }); } }; } catch (err) { console.log( `Error attempting to configure websocket for new wallet`, ); console.log(err); console.log(`Closing connection`); ws.close(); setWs(null); } } else { // If there is no websocket, create one, subscribe to addresses, and add notifications for incoming BCH transactions let newWs = new WebSocket('wss://ws.blockchain.info/bch/inv'); newWs.onopen = () => { console.log(`Connected to bchWs`); // Subscribe to BCH address newWs.send( JSON.stringify({ op: 'addr_sub', addr: cashAddress, }), ); console.log(`Subscribed to BCH address at ${cashAddress}`); // Subscribe to SLP address newWs.send( JSON.stringify({ op: 'addr_sub', addr: slpAddress, }), ); console.log(`Subscribed to SLP address at ${slpAddress}`); }; newWs.onerror = e => { // close and set to null console.log(`Error in websocket connection for ${newWs}`); console.log(e); setWs(null); }; newWs.onclose = () => { console.log(`Websocket connection closed`); // Unsubscribe on close to prevent double subscribing //{"op":"addr_unsub", "addr":"$bitcoin_address"} newWs.send( JSON.stringify({ op: 'addr_unsub', addr: cashAddress, }), ); console.log(`Unsubscribed from BCH address at ${cashAddress}`); newWs.send( JSON.stringify({ op: 'addr_sub', addr: slpAddress, }), ); console.log(`Unsubscribed from SLP address at ${slpAddress}`); }; newWs.onmessage = e => { // TODO handle case where receive multiple messages on one incoming transaction //console.log(`ws msg received`); const incomingTx = JSON.parse(e.data); console.log(incomingTx); let bchSatsReceived = 0; // First, check the inputs // If cashAddress or slpAddress are in the inputs, then this is a sent tx and should be ignored for notifications if ( incomingTx && incomingTx.x && incomingTx.x.inputs && incomingTx.x.out ) { const inputs = incomingTx.x.inputs; // Iterate over inputs and see if this transaction was sent by the active wallet for (let i = 0; i < inputs.length; i += 1) { if ( inputs[i].prev_out.addr === cashAddress || inputs[i].prev_out.addr === slpAddress ) { // console.log(`Found a sending tx, not notifying`); // This is a sent transaction and should be ignored by notification handlers return; } } // Iterate over outputs to determine receiving address const outputs = incomingTx.x.out; for (let i = 0; i < outputs.length; i += 1) { if (outputs[i].addr === cashAddress) { // console.log(`BCH transaction received`); bchSatsReceived += outputs[i].value; // handle } if (outputs[i].addr === slpAddress) { console.log(`SLP transaction received`); //handle // you would want to get the slp info using this endpoint: // https://rest.kingbch.com/v3/slp/txDetails/cb39dd04e07e172a37addfcb1d6e167dc52c01867ba21c9bf8b5acf4dd969a3f // But it does not work for unconfirmed txs // Hold off on slp tx notifications for now } } } // parse for receiving address // if received at cashAddress, parse for BCH amount, notify BCH received // if received at slpAddress, parse for token, notify SLP received // if those checks fail, could be from a 'sent' tx, ignore // Note, when you send an SLP tx, you get SLP change to SLP address and BCH change to BCH address // Also note, when you send an SLP tx, you often have inputs from both BCH and SLP addresses // This causes a sent SLP tx to register 4 times from the websocket // Best way to ignore this is to ignore any incoming utx.x with BCH or SLP address in the inputs // Notification for received BCHA if (bchSatsReceived > 0) { notification.success({ message: 'Transaction received', description: ( You received {bchSatsReceived / 1e8}{' '} {currency.ticker}! ), duration: 3, }); } }; setWs(newWs); } }; - const fetchBchPrice = async () => { + const fetchBchPrice = async ( + fiatCode = cashtabSettings ? cashtabSettings.fiatCurrency : 'usd', + ) => { // Split this variable out in case coingecko changes const cryptoId = currency.coingeckoId; - // Keep currency as a variable as eventually it will be a user setting - const fiatCode = 'usd'; // 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] / 1e6; // Error handling here // Temp condition until price API has supported rebrand // If eCash price is > 1, throw error // If eCash price is < 0.0000001, throw error // At BCHA price of $33, 1 XEC = 0.000033 // 0.000033 // 0.0000001 <== so if price is less than this, it's dividing an already correct price by 1,000,000 const validEcashPrice = bchPriceInFiat < 1 && bchPriceInFiat > 0.0000001; 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(() => { + useEffect(async () => { handleUpdateWallet(setWallet); - fetchBchPrice(); + const initialSettings = await loadCashtabSettings(); + initializeFiatPriceApi(initialSettings.fiatCurrency); }, []); useEffect(() => { if ( wallet && wallet.Path145 && wallet.Path145.cashAddress && wallet.Path245 && wallet.Path245.cashAddress ) { if (currency.useBlockchainWs) { initializeWebsocket( wallet.Path145.legacyAddress, wallet.Path245.legacyAddress, ); } } }, [wallet]); return { BCH, wallet, fiatPrice, slpBalancesAndUtxos, balances, tokens, parsedTxHistory, loading, apiError, + cashtabSettings, + changeCashtabSettings, getActiveWalletFromLocalForage, getWallet, validateMnemonic, getWalletDetails, getSavedWallets, migrateLegacyWallet, update: async () => update({ wallet: await getWallet(), setLoading, setWalletState, }), createWallet: async importMnemonic => { setLoading(true); const newWallet = await createWallet(importMnemonic); setWallet(newWallet); update({ wallet: newWallet, setWalletState, }).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, setWalletState, }).finally(() => setLoading(false)); } }, addNewSavedWallet, renameWallet, deleteWallet, }; }; export default useWallet; diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js index 27aabc426..190359f72 100644 --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -1,216 +1,228 @@ import { shouldRejectAmountInput, fiatToCrypto, isValidTokenName, isValidTokenTicker, isValidTokenDecimals, isValidTokenInitialQty, isValidTokenDocumentUrl, isValidTokenStats, + isValidCashtabSettings, } from '../validation'; import { currency } from '@components/Common/Ticker.js'; import { fromSmallestDenomination } from '@utils/cashMethods'; import { stStatsValid, noCovidStatsValid, noCovidStatsInvalid, cGenStatsValid, } from '../__mocks__/mockTokenStats'; describe('Validation utils', () => { it(`Returns 'false' if ${currency.ticker} send amount is a valid send amount`, () => { expect(shouldRejectAmountInput('10', currency.ticker, 20.0, 300)).toBe( false, ); }); it(`Returns 'false' if ${currency.ticker} send amount is a valid send amount in USD`, () => { // Here, user is trying to send $170 USD, where 1 BCHA = $20 USD, and the user has a balance of 15 BCHA or $300 expect(shouldRejectAmountInput('170', 'USD', 20.0, 15)).toBe(false); }); it(`Returns not a number if ${currency.ticker} send amount is not a number`, () => { const expectedValidationError = `Amount must be a number`; expect( shouldRejectAmountInput('Not a number', currency.ticker, 20.0, 3), ).toBe(expectedValidationError); }); it(`Returns amount must be greater than 0 if ${currency.ticker} send amount is 0`, () => { const expectedValidationError = `Amount must be greater than 0`; expect(shouldRejectAmountInput('0', currency.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns amount must be greater than 0 if ${currency.ticker} send amount is less than 0`, () => { const expectedValidationError = `Amount must be greater than 0`; expect( shouldRejectAmountInput('-0.031', currency.ticker, 20.0, 3), ).toBe(expectedValidationError); }); it(`Returns balance error if ${currency.ticker} send amount is greater than user balance`, () => { const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`; expect(shouldRejectAmountInput('17', currency.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns balance error if ${currency.ticker} send amount is greater than user balance`, () => { const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`; expect(shouldRejectAmountInput('17', currency.ticker, 20.0, 3)).toBe( expectedValidationError, ); }); it(`Returns error if ${ currency.ticker } send amount is less than ${fromSmallestDenomination( currency.dustSats, ).toString()} minimum`, () => { const expectedValidationError = `Send amount must be at least ${fromSmallestDenomination( currency.dustSats, ).toString()} ${currency.ticker}`; expect( shouldRejectAmountInput( ( fromSmallestDenomination(currency.dustSats).toString() - 0.00000001 ).toString(), currency.ticker, 20.0, 3, ), ).toBe(expectedValidationError); }); it(`Returns error if ${ currency.ticker } send amount is less than ${fromSmallestDenomination( currency.dustSats, ).toString()} minimum in fiat currency`, () => { const expectedValidationError = `Send amount must be at least ${fromSmallestDenomination( currency.dustSats, ).toString()} ${currency.ticker}`; expect( shouldRejectAmountInput('0.0000005', 'USD', 0.00005, 1000000), ).toBe(expectedValidationError); }); it(`Returns balance error if ${currency.ticker} send amount is greater than user balance with fiat currency selected`, () => { const expectedValidationError = `Amount cannot exceed your ${currency.ticker} balance`; // Here, user is trying to send $170 USD, where 1 BCHA = $20 USD, and the user has a balance of 5 BCHA or $100 expect(shouldRejectAmountInput('170', 'USD', 20.0, 5)).toBe( expectedValidationError, ); }); it(`Returns precision error if ${currency.ticker} send amount has more than ${currency.cashDecimals} decimal places`, () => { const expectedValidationError = `${currency.ticker} transactions do not support more than ${currency.cashDecimals} decimal places`; expect( shouldRejectAmountInput('17.123456789', currency.ticker, 20.0, 35), ).toBe(expectedValidationError); }); it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have higher precision`, () => { expect(fiatToCrypto('10.97231694823432', 20.3231342349234234, 8)).toBe( '0.53989295', ); }); it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have higher precision`, () => { expect(fiatToCrypto('10.97231694823432', 20.3231342349234234, 2)).toBe( '0.54', ); }); it(`Returns expected crypto amount with ${currency.cashDecimals} decimals of precision even if inputs have lower precision`, () => { expect(fiatToCrypto('10.94', 10, 8)).toBe('1.09400000'); }); it(`Accepts a valid ${currency.tokenTicker} token name`, () => { expect(isValidTokenName('Valid token name')).toBe(true); }); it(`Accepts a valid ${currency.tokenTicker} token name that is a stringified number`, () => { expect(isValidTokenName('123456789')).toBe(true); }); it(`Rejects ${currency.tokenTicker} token name if longer than 68 characters`, () => { expect( isValidTokenName( 'This token name is not valid because it is longer than 68 characters which is really pretty long for a token name when you think about it and all', ), ).toBe(false); }); it(`Rejects ${currency.tokenTicker} token name if empty string`, () => { expect(isValidTokenName('')).toBe(false); }); it(`Accepts a 4-char ${currency.tokenTicker} token ticker`, () => { expect(isValidTokenTicker('DOGE')).toBe(true); }); it(`Accepts a 12-char ${currency.tokenTicker} token ticker`, () => { expect(isValidTokenTicker('123456789123')).toBe(true); }); it(`Rejects ${currency.tokenTicker} token ticker if empty string`, () => { expect(isValidTokenTicker('')).toBe(false); }); it(`Rejects ${currency.tokenTicker} token ticker if > 12 chars`, () => { expect(isValidTokenTicker('1234567891234')).toBe(false); }); it(`Accepts ${currency.tokenDecimals} if zero`, () => { expect(isValidTokenDecimals('0')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} if between 0 and 9 inclusive`, () => { expect(isValidTokenDecimals('9')).toBe(true); }); it(`Rejects ${currency.tokenDecimals} if empty string`, () => { expect(isValidTokenDecimals('')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} if non-integer`, () => { expect(isValidTokenDecimals('1.7')).toBe(false); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity at minimum amount for 3 decimal places`, () => { expect(isValidTokenInitialQty('0.001', '3')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity at minimum amount for 9 decimal places`, () => { expect(isValidTokenInitialQty('0.000000001', '9')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity at amount below 100 billion`, () => { expect(isValidTokenInitialQty('1000', '0')).toBe(true); }); it(`Accepts highest possible ${currency.tokenDecimals} initial genesis quantity at amount below 100 billion`, () => { expect(isValidTokenInitialQty('99999999999.999999999', '9')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity if decimal places equal tokenDecimals`, () => { expect(isValidTokenInitialQty('0.123', '3')).toBe(true); }); it(`Accepts ${currency.tokenDecimals} initial genesis quantity if decimal places are less than tokenDecimals`, () => { expect(isValidTokenInitialQty('0.12345', '9')).toBe(true); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity of zero`, () => { expect(isValidTokenInitialQty('0', '9')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity if tokenDecimals is not valid`, () => { expect(isValidTokenInitialQty('0', '')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity if 100 billion or higher`, () => { expect(isValidTokenInitialQty('100000000000', '0')).toBe(false); }); it(`Rejects ${currency.tokenDecimals} initial genesis quantity if it has more decimal places than tokenDecimals`, () => { expect(isValidTokenInitialQty('1.5', '0')).toBe(false); }); it(`Accepts a valid ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('cashtabapp.com')).toBe(true); }); it(`Accepts a valid ${currency.tokenTicker} token document URL including special URL characters`, () => { expect(isValidTokenDocumentUrl('https://cashtabapp.com/')).toBe(true); }); it(`Accepts a blank string as a valid ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl('')).toBe(true); }); it(`Rejects ${currency.tokenTicker} token name if longer than 68 characters`, () => { expect( isValidTokenDocumentUrl( 'http://www.ThisTokenDocumentUrlIsActuallyMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchMuchTooLong.com/', ), ).toBe(false); }); it(`Correctly validates token stats for token created before the ${currency.ticker} fork`, () => { expect(isValidTokenStats(stStatsValid)).toBe(true); }); it(`Correctly validates token stats for token created after the ${currency.ticker} fork`, () => { expect(isValidTokenStats(noCovidStatsValid)).toBe(true); }); it(`Correctly validates token stats for token with no minting baton`, () => { expect(isValidTokenStats(cGenStatsValid)).toBe(true); }); it(`Recognizes a token stats object with missing required keys as invalid`, () => { expect(isValidTokenStats(noCovidStatsInvalid)).toBe(false); }); + it(`Recognizes a valid cashtab settings object`, () => { + expect(isValidCashtabSettings({ fiatCurrency: 'usd' })).toBe(true); + }); + it(`Rejects a cashtab settings object for an unsupported currency`, () => { + expect(isValidCashtabSettings({ fiatCurrency: 'jpy' })).toBe(false); + }); + it(`Rejects a corrupted cashtab settings object for an unsupported currency`, () => { + expect(isValidCashtabSettings({ fiatCurrencyWrongLabel: 'usd' })).toBe( + false, + ); + }); }); diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js index 7c12b71dc..646eeb1e9 100644 --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -1,107 +1,121 @@ import BigNumber from 'bignumber.js'; import { currency } from '@components/Common/Ticker.js'; import { fromSmallestDenomination } from '@utils/cashMethods'; // Validate cash amount export const shouldRejectAmountInput = ( cashAmount, selectedCurrency, fiatPrice, totalCashBalance, ) => { // Take cashAmount as input, a string from form input let error = false; let testedAmount = new BigNumber(cashAmount); - if (selectedCurrency === 'USD') { - // Ensure no more than 8 decimal places + if (selectedCurrency !== currency.ticker) { + // Ensure no more than currency.cashDecimals decimal places testedAmount = new BigNumber(fiatToCrypto(cashAmount, fiatPrice)); } // Validate value for > 0 if (isNaN(testedAmount)) { error = 'Amount must be a number'; } else if (testedAmount.lte(0)) { error = 'Amount must be greater than 0'; } else if ( testedAmount.lt(fromSmallestDenomination(currency.dustSats).toString()) ) { error = `Send amount must be at least ${fromSmallestDenomination( currency.dustSats, ).toString()} ${currency.ticker}`; } else if (testedAmount.gt(totalCashBalance)) { error = `Amount cannot exceed your ${currency.ticker} balance`; } else if (!isNaN(testedAmount) && testedAmount.toString().includes('.')) { if ( testedAmount.toString().split('.')[1].length > currency.cashDecimals ) { error = `${currency.ticker} transactions do not support more than ${currency.cashDecimals} decimal places`; } } // return false if no error, or string error msg if error return error; }; export const fiatToCrypto = ( fiatAmount, fiatPrice, cashDecimals = currency.cashDecimals, ) => { let cryptoAmount = new BigNumber(fiatAmount) .div(new BigNumber(fiatPrice)) .toFixed(cashDecimals); return cryptoAmount; }; export const isValidTokenName = tokenName => { return ( typeof tokenName === 'string' && tokenName.length > 0 && tokenName.length < 68 ); }; export const isValidTokenTicker = tokenTicker => { return ( typeof tokenTicker === 'string' && tokenTicker.length > 0 && tokenTicker.length < 13 ); }; export const isValidTokenDecimals = tokenDecimals => { return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes( tokenDecimals, ); }; export const isValidTokenInitialQty = (tokenInitialQty, tokenDecimals) => { const minimumQty = new BigNumber(1 / 10 ** tokenDecimals); const tokenIntialQtyBig = new BigNumber(tokenInitialQty); return ( tokenIntialQtyBig.gte(minimumQty) && tokenIntialQtyBig.lt(100000000000) && tokenIntialQtyBig.dp() <= tokenDecimals ); }; export const isValidTokenDocumentUrl = tokenDocumentUrl => { return ( typeof tokenDocumentUrl === 'string' && tokenDocumentUrl.length >= 0 && tokenDocumentUrl.length < 68 ); }; export const isValidTokenStats = tokenStats => { return ( typeof tokenStats === 'object' && 'timestampUnix' in tokenStats && 'documentUri' in tokenStats && 'containsBaton' in tokenStats && 'initialTokenQty' in tokenStats && 'totalMinted' in tokenStats && 'totalBurned' in tokenStats && 'circulatingSupply' in tokenStats ); }; + +export const isValidCashtabSettings = settings => { + try { + const isValid = + typeof settings === 'object' && + Object.prototype.hasOwnProperty.call(settings, 'fiatCurrency') && + currency.settingsValidation.fiatCurrency.includes( + settings.fiatCurrency, + ); + return isValid; + } catch (err) { + return false; + } +};