diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js index 4a5ff23ea..8a50ae2de 100644 --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -1,113 +1,123 @@ import mainLogo from '@assets/12-bitcoin-cash-square-crop.svg'; import tokenLogo from '@assets/simple-ledger-protocol-logo.png'; import cashaddr from 'cashaddrjs'; import BigNumber from 'bignumber.js'; export const currency = { name: 'Bitcoin ABC', ticker: 'BCHA', logo: mainLogo, prefixes: ['bitcoincash:', 'ecash:'], coingeckoId: 'bitcoin-cash-abc-2', defaultFee: 5.01, blockExplorerUrl: 'https://explorer.bitcoinabc.org', blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org', tokenName: 'Bitcoin ABC SLP', tokenTicker: 'SLPA', tokenLogo: tokenLogo, tokenPrefixes: ['simpleledger:', 'etoken:'], tokenIconsUrl: '', //https://tokens.bitcoin.com/32 for BCH SLP useBlockchainWs: false, }; export function isCash(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 for (let i = 0; i < currency.prefixes.length; i += 1) { if (addressString.startsWith(currency.prefixes[i])) { return true; } } return false; } export function isToken(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 (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; + let hasPrefix = address.includes(':'); + + if (!hasPrefix) { + testedAddress = `bitcoincash:` + address; + } else { + testedAddress = address; + } try { - if (isCash(address)) { - const { type, hash } = cashaddr.decode(address); + if (isCash(testedAddress)) { + const { type, hash } = cashaddr.decode(testedAddress); legacyAddress = cashaddr.encode('bitcoincash', type, hash); - console.log(`legacyAddress`); } else { - throw new Error('Address prefix is not in Ticker.prefixes array'); + 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/Common/__tests__/Ticker.test.js b/web/cashtab/src/components/Common/__tests__/Ticker.test.js index 9e7a6081e..35ab08e7a 100644 --- a/web/cashtab/src/components/Common/__tests__/Ticker.test.js +++ b/web/cashtab/src/components/Common/__tests__/Ticker.test.js @@ -1,81 +1,90 @@ import { ValidationError } from 'cashaddrjs'; import { isCash, isToken, toLegacy } from '../Ticker'; test('Correctly validates cash address with bitcoincash: prefix', async () => { const result = isCash( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); expect(result).toStrictEqual(true); }); test('Correctly validates cash address with ecash: prefix', async () => { const result = isCash('ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc'); expect(result).toStrictEqual(true); }); test('Correctly validates token address with simpleledger: prefix', async () => { const result = isToken( 'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(true); }); test('Correctly validates token address with etoken: prefix (prefix only, not checksum)', async () => { const result = isToken('etoken:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm'); expect(result).toStrictEqual(true); }); test('Recognizes unaccepted token prefix (prefix only, not checksum)', async () => { const result = isToken( 'wtftoken:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(false); }); test('Knows that acceptable cash prefixes are not tokens', async () => { const result = isToken('ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc'); expect(result).toStrictEqual(false); }); test('Address with unlisted prefix is invalid', async () => { const result = isCash( 'ecashdoge:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual(false); }); test('toLegacy() converts a valid ecash: prefix address to a valid bitcoincash: prefix address', async () => { const result = toLegacy('ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc'); expect(result).toStrictEqual( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); }); +test('toLegacy() accepts a valid BCH address with no prefix and returns with prefix', async () => { + const result = toLegacy('qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0'); + expect(result).toStrictEqual( + 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', + ); +}); + test('toLegacy() returns a valid bitcoincash: prefix address unchanged', async () => { const result = toLegacy( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); expect(result).toStrictEqual( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); }); test('toLegacy throws error if input address has invalid checksum', async () => { const result = toLegacy('ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25m'); expect(result).toStrictEqual( new ValidationError( 'Invalid checksum: ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25m.', ), ); }); test('toLegacy throws error if input address has invalid prefix', async () => { const result = toLegacy( 'notecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual( - new Error('Address prefix is not in Ticker.prefixes array'), + new Error( + 'Address prefix is not a valid cash address with a prefix from the Ticker.prefixes array', + ), ); }); diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js index 5754acf28..bfce7764a 100644 --- a/web/cashtab/src/components/Send/Send.js +++ b/web/cashtab/src/components/Send/Send.js @@ -1,529 +1,540 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; 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, isToken, parseAddress, toLegacy, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; export const BalanceHeader = styled.div` p { color: #777; width: 100%; font-size: 14px; margin-bottom: 0px; } h3 { color: #444; width: 100%; font-size: 26px; font-weight: bold; margin-bottom: 0px; } `; export const BalanceHeaderFiat = styled.div` color: #444; width: 100%; font-size: 18px; margin-bottom: 20px; font-weight: bold; @media (max-width: 768px) { font-size: 16px; } `; export const ZeroBalanceHeader = styled.div` color: #444; width: 100%; font-size: 14px; margin-bottom: 20px; `; const ConvertAmount = styled.div` color: #777; width: 100%; font-size: 14px; margin-bottom: 10px; font-weight: bold; @media (max-width: 768px) { font-size: 12px; } `; const SendBCH = ({ filledAddress, callbackTxId }) => { const { wallet, fiatPrice, balances, slpBalancesAndUtxos, apiError, } = React.useContext(WalletContext); // 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(); const BCH = 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); - // If there was an error converting the address - if (!cleanAddress.startsWith('bitcoincash:')) { - // return as above with other errors + let hasValidCashPrefix; + try { + hasValidCashPrefix = cleanAddress.startsWith('bitcoincash:'); + } catch (err) { + hasValidCashPrefix = false; console.log(`toLegacy() returned an error:`, cleanAddress); - // Note: the address must be valid to get to this point, so unsure if this can be produced + } + + 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') { bchValue = (value / fiatPrice).toFixed(8); } try { const link = await sendBch( BCH, wallet, slpBalancesAndUtxos.nonSlpUtxos, { addresses: [filledAddress || cleanAddress], values: [bchValue], }, 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 = 'Address is not a valid cash address'; // If valid address but token format if (isToken(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 error = false; let bchValue = value; if (selectedCurrency === 'USD') { bchValue = (value / fiatPrice).toFixed(8); } // Validate value for > 0 if (isNaN(bchValue)) { error = 'Amount must be a number'; } else if (bchValue <= 0) { error = 'Amount must be greater than 0'; } else if (bchValue < 0.00001) { error = `Send amount must be at least 0.00001 ${currency.ticker}`; } else if (bchValue > balances.totalBalance) { error = `Amount cannot exceed your ${currency.ticker} balance`; } else if (!isNaN(bchValue) && bchValue.toString().includes('.')) { if (bchValue.toString().split('.')[1].length > 8) { error = `${currency.ticker} transactions do not support more than 8 decimal places`; } } 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 / 1e8; let value = balances.totalBalance - txFeeBch >= 0 ? (balances.totalBalance - txFeeBch).toFixed(8) : 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`; } else { fiatPriceString = `${(Number(formData.value) / fiatPrice).toFixed( 8, )} ${currency.ticker}`; } } 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
) : ( <>

Available balance

{balances.totalBalance} {currency.ticker}

{fiatPrice !== null && ( ${(balances.totalBalance * fiatPrice).toFixed(2)}{' '} 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), }} > = {fiatPriceString}
{!balances.totalBalance || apiError || sendBchAmountError || sendBchAddressError ? ( Send ) : ( <> {txInfoFromUrl ? ( showModal()} > Send ) : ( submit()} > Send )} )}
{queryStringText && ( )} {apiError && ( <>

An error occured on our end. Reconnecting...

)}
); }; export default SendBCH;