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
) : (
<>