diff --git a/web/cashtab/src/components/Common/EnhancedInputs.js b/web/cashtab/src/components/Common/EnhancedInputs.js index 48d3cff7b..1cb799aee 100644 --- a/web/cashtab/src/components/Common/EnhancedInputs.js +++ b/web/cashtab/src/components/Common/EnhancedInputs.js @@ -1,342 +1,361 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { Form, Input, Select } from 'antd'; +const { TextArea } = Input; 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; } textarea.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; min-height: 100px; } .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: activeFiatCode ? activeFiatCode : 'USD', label: activeFiatCode ? activeFiatCode : 'USD', }, ]; const currencyOptions = currencies.map(currency => { return ( {currency.label} ); }); const CurrencySelect = ( {currencyOptions} ); return ( ) : ( ) } {...inputProps} /> {CurrencySelect} max ); }; SendBchInput.propTypes = { onMax: PropTypes.func, inputProps: PropTypes.object, selectProps: PropTypes.object, activeFiatCode: PropTypes.string, }; export const FormItemWithMaxAddon = ({ onMax, inputProps, ...otherProps }) => { return ( } addonAfter={ max } {...inputProps} /> ); }; FormItemWithMaxAddon.propTypes = { onMax: PropTypes.func, inputProps: PropTypes.object, }; // loadWithCameraOpen prop: if true, load page with camera scanning open export const DestinationAddressSingle = ({ onScan, loadWithCameraOpen, inputProps, ...otherProps }) => { return ( } autoComplete="off" addonAfter={ } {...inputProps} /> ); }; DestinationAddressSingle.propTypes = { onScan: PropTypes.func, loadWithCameraOpen: PropTypes.bool, inputProps: PropTypes.object, }; +export const DestinationAddressMulti = ({ inputProps, ...otherProps }) => { + return ( + + + } + autoComplete="off" + {...inputProps} + /> + + + ); +}; + +DestinationAddressMulti.propTypes = { + inputProps: PropTypes.object, +}; + 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 ( {currencyMenuOption.label} ); }); return ( {currencyOptions} ); }; 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 f8b739713..13dd79e8f 100644 --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -1,272 +1,330 @@ import mainLogo from '@assets/logo_primary.png'; import tokenLogo from '@assets/logo_secondary.png'; import cashaddr from 'ecashaddrjs'; import BigNumber from 'bignumber.js'; export const currency = { name: 'eCash', ticker: 'XEC', appUrl: 'cashtab.com', logo: mainLogo, legacyPrefix: 'bitcoincash', prefixes: ['ecash'], coingeckoId: 'ecash', defaultFee: 2.01, dustSats: 550, etokenSats: 546, cashDecimals: 2, blockExplorerUrl: 'https://explorer.bitcoinabc.org', tokenExplorerUrl: 'https://explorer.be.cash', blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org', tokenName: 'eToken', tokenTicker: 'eToken', tokenIconSubmitApi: 'https://icons.etokens.cash/new', tokenLogo: tokenLogo, tokenPrefixes: ['etoken'], tokenIconsUrl: 'https://etoken-icons.s3.us-west-2.amazonaws.com', txHistoryCount: 5, hydrateUtxoBatchSize: 20, defaultSettings: { fiatCurrency: 'usd' }, notificationDurationShort: 3, notificationDurationLong: 5, newTokenDefaultUrl: 'https://cashtab.com/', opReturn: { opReturnPrefixHex: '6a', opReturnPushDataHex: '04', opReturnAppPrefixLengthHex: '04', appPrefixesHex: { eToken: '534c5000', cashtab: '00746162', }, }, settingsValidation: { fiatCurrency: [ 'usd', 'idr', 'krw', 'cny', 'zar', 'vnd', 'cad', 'nok', 'eur', 'gbp', 'jpy', 'try', 'rub', 'inr', 'brl', 'php', 'ils', 'clp', 'twd', 'hkd', 'bhd', 'sar', 'aud', 'nzd', ], }, fiatCurrencies: { usd: { name: 'US Dollar', symbol: '$', slug: 'usd' }, aud: { name: 'Australian Dollar', symbol: '$', slug: 'aud' }, bhd: { name: 'Bahraini Dinar', symbol: 'BD', slug: 'bhd' }, brl: { name: 'Brazilian Real', symbol: 'R$', slug: 'brl' }, gbp: { name: 'British Pound', symbol: '£', slug: 'gbp' }, cad: { name: 'Canadian Dollar', symbol: '$', slug: 'cad' }, clp: { name: 'Chilean Peso', symbol: '$', slug: 'clp' }, cny: { name: 'Chinese Yuan', symbol: '元', slug: 'cny' }, eur: { name: 'Euro', symbol: '€', slug: 'eur' }, hkd: { name: 'Hong Kong Dollar', symbol: 'HK$', slug: 'hkd' }, inr: { name: 'Indian Rupee', symbol: '₹', slug: 'inr' }, idr: { name: 'Indonesian Rupiah', symbol: 'Rp', slug: 'idr' }, ils: { name: 'Israeli Shekel', symbol: '₪', slug: 'ils' }, jpy: { name: 'Japanese Yen', symbol: '¥', slug: 'jpy' }, krw: { name: 'Korean Won', symbol: '₩', slug: 'krw' }, nzd: { name: 'New Zealand Dollar', symbol: '$', slug: 'nzd' }, nok: { name: 'Norwegian Krone', symbol: 'kr', slug: 'nok' }, php: { name: 'Philippine Peso', symbol: '₱', slug: 'php' }, rub: { name: 'Russian Ruble', symbol: 'р.', slug: 'rub' }, twd: { name: 'New Taiwan Dollar', symbol: 'NT$', slug: 'twd' }, sar: { name: 'Saudi Riyal', symbol: 'SAR', slug: 'sar' }, zar: { name: 'South African Rand', symbol: 'R', slug: 'zar' }, try: { name: 'Turkish Lira', symbol: '₺', slug: 'try' }, vnd: { name: 'Vietnamese đồng', symbol: 'đ', slug: 'vnd' }, }, }; export function getETokenEncodingSubstring() { let encodingStr = currency.opReturn.opReturnPrefixHex + // 6a currency.opReturn.opReturnAppPrefixLengthHex + // 04 currency.opReturn.appPrefixesHex.eToken; // 534c5000 return encodingStr; } export function getCashtabEncodingSubstring() { let encodingStr = currency.opReturn.opReturnPrefixHex + // 6a currency.opReturn.opReturnAppPrefixLengthHex + // 04 currency.opReturn.appPrefixesHex.cashtab; // 00746162 return encodingStr; } export function isCashtabOutput(hexStr) { if (!hexStr || typeof hexStr !== 'string') { return false; } return hexStr.startsWith(getCashtabEncodingSubstring()); } export function isEtokenOutput(hexStr) { if (!hexStr || typeof hexStr !== 'string') { return false; } return hexStr.startsWith(getETokenEncodingSubstring()); } export function extractCashtabMessage(hexSubstring) { if (!hexSubstring || typeof hexSubstring !== 'string') { return ''; } let substring = hexSubstring.replace(getCashtabEncodingSubstring(), ''); // remove the cashtab encoding substring = substring.slice(2); // remove the 2 bytes indicating the size of the next element on the stack e.g. a0 -> 160 bytes return substring; } export function extractExternalMessage(hexSubstring) { if (!hexSubstring || typeof hexSubstring !== 'string') { return ''; } let substring = hexSubstring.slice(4); // remove the preceding OP_RETURN prefixes return substring; } 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 toLegacyArray(addressArray) { + let cleanArray = []; // array of bch converted addresses to be returned + + try { + if ( + addressArray === null || + addressArray === undefined || + !addressArray.length || + addressArray === '' + ) { + throw new Error('Invalid addressArray input'); + } + + const arrayLength = addressArray.length; + + for (let i = 0; i < arrayLength; i++) { + let testedAddress; + let legacyAddress; + let addressValueArr = addressArray[i].split(','); + let address = addressValueArr[0]; + let value = addressValueArr[1]; + + 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, + ); + + let convertedArrayData = legacyAddress + ',' + value + '\n'; + cleanArray.push(convertedArrayData); + } 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 cleanArray; +} + export function parseAddress(BCH, addressString, isToken = false) { // 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); // Only accept addresses with ecash: prefix const { prefix } = cashaddr.decode(cleanAddress); // If the address does not have a valid prefix or token prefix if ( (!isToken && !currency.prefixes.includes(prefix)) || (isToken && !currency.tokenPrefixes.includes(prefix)) ) { // then it is not a valid destination address for XEC sends isValidAddress = false; } } 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(10 ** currency.cashDecimals) .toString(); } catch (err) { amount = null; } } } addressInfo.amount = amount; return addressInfo; } diff --git a/web/cashtab/src/components/Common/__mocks__/mockAddressArray.js b/web/cashtab/src/components/Common/__mocks__/mockAddressArray.js new file mode 100644 index 000000000..095d648ea --- /dev/null +++ b/web/cashtab/src/components/Common/__mocks__/mockAddressArray.js @@ -0,0 +1,252 @@ +export const invalidAddressArrayInput = [ + 'ecash:qrqgwxrrrrrrrrrrrrrrrrrrrrrrrrr7zsvk,7', + 'ecash:qzsha6zffffffffffffffffffffffffffffwuuzel2lfzv,67', + 'ecash:qqlkeeeeeeeeeeeeeeee4wyz0v403dj,4376', + 'ecash:qz2taawwwwwwwwwwwwwwwwwwwqfqjrqst4,673728', + 'ecash:qz2tggggggggggggggggggzclqfqjrqst4,368', + 'ecash:qp0hlj2rrrrrrrrrrrrrrrrrrfhckq4zj9s6,23673', +]; + +export const validAddressArrayInput = [ + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', +]; + +export const validAddressArrayOutput = [ + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', +]; + +export const validLargeAddressArrayInput = [ + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', + 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67', + 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728', + 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,368', + 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673', + 'ecash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvtdy0n9s0,983', + 'ecash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5rgq8q3ks,7834867', + 'ecash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alcy8kq39f2,348732', + 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3272377', +]; + +export const validLargeAddressArrayOutput = [ + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', + 'bitcoincash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dy33r4e22p,7\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,67\n', + 'bitcoincash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wymzc75tt9,4376\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,673728\n', + 'bitcoincash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqsdxgm2dz,368\n', + 'bitcoincash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhc0dpfflkd,23673\n', + 'bitcoincash:qzju5ql7lk2h4k5uj0cukmu6zg859zsjjvjqsyglkc,983\n', + 'bitcoincash:qq3ap93nt5trdtjuum0adv7n29xx7gl2a5695vmts8,7834867\n', + 'bitcoincash:qzq7a8vpzq5wyqgka0r3wk5t90rxw75alca2zt2l0a,348732\n', + 'bitcoincash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuum5tpynym,3272377\n', +]; diff --git a/web/cashtab/src/components/Common/__tests__/Ticker.test.js b/web/cashtab/src/components/Common/__tests__/Ticker.test.js index 97f978f22..4dd66fe45 100644 --- a/web/cashtab/src/components/Common/__tests__/Ticker.test.js +++ b/web/cashtab/src/components/Common/__tests__/Ticker.test.js @@ -1,191 +1,245 @@ import { ValidationError } from 'ecashaddrjs'; import { isValidCashPrefix, isValidTokenPrefix, toLegacy, + toLegacyArray, isCashtabOutput, isEtokenOutput, extractCashtabMessage, extractExternalMessage, getETokenEncodingSubstring, getCashtabEncodingSubstring, } from '../Ticker'; +import { + validAddressArrayInput, + validAddressArrayOutput, + validLargeAddressArrayInput, + validLargeAddressArrayOutput, + invalidAddressArrayInput, +} from '../__mocks__/mockAddressArray'; test('Rejects cash address with bitcoincash: prefix', async () => { const result = isValidCashPrefix( 'bitcoincash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gjykk3wa0', ); expect(result).toStrictEqual(false); }); test('Correctly validates cash address with bitcoincash: checksum but no prefix', async () => { const result = isValidCashPrefix( 'qphpmfj0qn7znklqhrfn5dq7qh36l3vxavu346vqcl', ); expect(result).toStrictEqual(true); }); test('Correctly validates cash address with ecash: checksum but no prefix', async () => { const result = isValidCashPrefix( 'qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual(true); }); test('Correctly validates cash address with ecash: prefix', async () => { const result = isValidCashPrefix( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual(true); }); test('Rejects token address with simpleledger: prefix', async () => { const result = isValidTokenPrefix( 'simpleledger:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(false); }); test('Does not accept a valid token address without a prefix', async () => { const result = isValidTokenPrefix( 'qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(false); }); test('Correctly validates token address with etoken: prefix (prefix only, not checksum)', async () => { const result = isValidTokenPrefix( 'etoken:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(true); }); test('Recognizes unaccepted token prefix (prefix only, not checksum)', async () => { const result = isValidTokenPrefix( 'wtftoken:qpmytrdsakt0axrrlswvaj069nat3p9s7c8w5tu8gm', ); expect(result).toStrictEqual(false); }); test('Knows that acceptable cash prefixes are not tokens', async () => { const result = isValidTokenPrefix( 'ecash:qqd3qn4zazjhygk5a2vzw2gvqgqwempr4gtfza25mc', ); expect(result).toStrictEqual(false); }); test('Address with unlisted prefix is invalid', async () => { const result = isValidCashPrefix( '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 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 a valid cash address with a prefix from the Ticker.prefixes array', ), ); }); +test('toLegacyArray throws error if the addressArray input is null', async () => { + const result = toLegacyArray(null); + + expect(result).toStrictEqual(new Error('Invalid addressArray input')); +}); + +test('toLegacyArray throws error if the addressArray input is empty', async () => { + const result = toLegacyArray([]); + + expect(result).toStrictEqual(new Error('Invalid addressArray input')); +}); + +test('toLegacyArray throws error if the addressArray input is a number', async () => { + const result = toLegacyArray(12345); + + expect(result).toStrictEqual(new Error('Invalid addressArray input')); +}); + +test('toLegacyArray throws error if the addressArray input is undefined', async () => { + const result = toLegacyArray(undefined); + + expect(result).toStrictEqual(new Error('Invalid addressArray input')); +}); + +test('toLegacyArray successfully converts a standard sized valid addressArray input', async () => { + const result = toLegacyArray(validAddressArrayInput); + + expect(result).toStrictEqual(validAddressArrayOutput); +}); + +test('toLegacyArray successfully converts a large valid addressArray input', async () => { + const result = toLegacyArray(validLargeAddressArrayInput); + + expect(result).toStrictEqual(validLargeAddressArrayOutput); +}); + +test('toLegacyArray throws an error on an addressArray with invalid addresses', async () => { + const result = toLegacyArray(invalidAddressArrayInput); + + expect(result).toStrictEqual( + new ValidationError( + 'Invalid checksum: ecash:qrqgwxrrrrrrrrrrrrrrrrrrrrrrrrr7zsvk.', + ), + ); +}); + test('getCashtabEncodingSubstring() returns the appropriate substring for cashtab message outputs', async () => { const result = getCashtabEncodingSubstring(); expect(result).toStrictEqual('6a0400746162'); }); test('getETokenEncodingSubstring() returns the appropriate substring for eToken outputs', async () => { const result = getETokenEncodingSubstring(); expect(result).toStrictEqual('6a04534c5000'); }); test('isCashtabOutput() correctly validates a cashtab message output hex', async () => { const result = isCashtabOutput('6a04007461620b63617368746162756c6172'); expect(result).toStrictEqual(true); }); test('isCashtabOutput() correctly invalidates an external message output hex', async () => { const result = isCashtabOutput('6a0c7069616e6f74656e6e697332'); expect(result).toStrictEqual(false); }); test('isCashtabOutput() correctly handles null input', async () => { const result = isCashtabOutput(null); expect(result).toStrictEqual(false); }); test('isCashtabOutput() correctly handles non-string input', async () => { const result = isCashtabOutput(7623723323); expect(result).toStrictEqual(false); }); test('isCashtabOutput() correctly invalidates an external message output hex', async () => { const result = isCashtabOutput( '6a202731afddf3b83747943f0e650b938ea0670dcae2e08c415f53bd4c6acfd15e09', ); expect(result).toStrictEqual(false); }); test('isEtokenOutput() correctly validates an eToken output hex', async () => { const result = isEtokenOutput( '6a04534c500001010453454e442069b8431ddecf775393b1b36aa1d0ddcd7b342f1157b9671a03747378ed35ea0d08000000000000012c080000000000002008', ); expect(result).toStrictEqual(true); }); test('isEtokenOutput() correctly invalidates an eToken output hex', async () => { const result = isEtokenOutput( '5434c500001010453454e442069b8431ddecf775393b1b36aa1d0ddcd7b342f1157b9671a03747378ed35ea0d08000000000000012c080000000000002008', ); expect(result).toStrictEqual(false); }); test('isEtokenOutput() correctly handles null input', async () => { const result = isEtokenOutput(null); expect(result).toStrictEqual(false); }); test('isEtokenOutput() correctly handles non-string input', async () => { const result = isEtokenOutput(7623723323); expect(result).toStrictEqual(false); }); test('extractCashtabMessage() correctly extracts a Cashtab message', async () => { const result = extractCashtabMessage( '6a04007461620b63617368746162756c6172', ); expect(result).toStrictEqual('63617368746162756c6172'); }); test('extractExternalMessage() correctly extracts an external message', async () => { const result = extractExternalMessage('6a0d62696e676f656c65637472756d'); expect(result).toStrictEqual('62696e676f656c65637472756d'); }); diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js index 9e5aeca80..7de9f891a 100644 --- a/web/cashtab/src/components/Send/Send.js +++ b/web/cashtab/src/components/Send/Send.js @@ -1,768 +1,949 @@ import React, { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import PropTypes from 'prop-types'; import { WalletContext } from '@utils/context'; import { AntdFormWrapper, SendBchInput, DestinationAddressSingle, + DestinationAddressMulti, } from '@components/Common/EnhancedInputs'; import { StyledCollapse, AdvancedCollapse, } from '@components/Common/StyledCollapse'; import { Form, message, Modal, Alert, Collapse, Input, + Button, notification, } from 'antd'; const { Panel } = Collapse; const { TextArea } = Input; import { Row, Col } from 'antd'; import Paragraph from 'antd/lib/typography/Paragraph'; import PrimaryButton, { SecondaryButton, SmartButton, } from '@components/Common/PrimaryButton'; import useBCH from '@hooks/useBCH'; import useWindowDimensions from '@hooks/useWindowDimensions'; import { sendXecNotification, errorNotification, messageSignedNotification, } from '@components/Common/Notifications'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { currency, isValidTokenPrefix, parseAddress, toLegacy, + toLegacyArray, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; -import { fiatToCrypto, shouldRejectAmountInput } from '@utils/validation'; +import { + fiatToCrypto, + shouldRejectAmountInput, + isValidSendToMany, +} from '@utils/validation'; import BalanceHeader from '@components/Common/BalanceHeader'; import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat'; import { ZeroBalanceHeader, ConvertAmount, AlertMsg, } from '@components/Common/Atoms'; import { getWalletState } from '@utils/cashMethods'; import ApiError from '@components/Common/ApiError'; import { formatFiatBalance } from '@utils/validation'; import WalletLabel from '@components/Common/WalletLabel.js'; import Wallet from '@components/Wallet/Wallet'; import { TokenParamLabel } from '@components/Common/Atoms'; import { PlusSquareOutlined } from '@ant-design/icons'; import styled from 'styled-components'; import { convertToEcashPrefix } from '@utils/cashMethods'; import { CopyToClipboard } from 'react-copy-to-clipboard'; const StyledSpacer = styled.div` height: 1px; width: 100%; background-color: ${props => props.theme.wallet.borders.color}; margin: 60px 0 50px; `; const SignMessageLabel = styled.div` text-align: left; color: #0074c2; `; +const RecipientModeLabel = styled.div` + color: silver; +`; const TextAreaLabel = styled.div` text-align: left; color: #0074c2; padding-left: 1px; `; // Note jestBCH is only used for unit tests; BCHJS must be mocked for jest const SendBCH = ({ jestBCH, passLoadingStatus }) => { // 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 location = useLocation(); const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; const walletState = getWalletState(wallet); const { balances, slpBalancesAndUtxos } = walletState; // Modal settings const [showConfirmMsgToSign, setShowConfirmMsgToSign] = useState(false); const [msgToSign, setMsgToSign] = useState(''); const [signMessageIsValid, setSignMessageIsValid] = useState(null); + const [isOneToManyXECSend, setIsOneToManyXECSend] = useState(false); // 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: '', }); 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 [messageSignature, setMessageSignature] = useState(''); const [sigCopySuccess, setSigCopySuccess] = useState(''); const showModal = () => { setIsModalVisible(true); }; const handleOk = () => { setIsModalVisible(false); - submit(); + send(); }; const handleCancel = () => { setIsModalVisible(false); }; - const { getBCH, getRestUrl, sendBch, calcFee, signPkMessage } = useBCH(); + const { getBCH, getRestUrl, sendXec, calcFee, signPkMessage } = 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(() => { passLoadingStatus(false); }, [balances.totalBalance]); useEffect(() => { // Manually parse for txInfo object on page load when Send.js is loaded with a query string // if this was routed from Wallet screen's Reply to message link then prepopulate the address and value field if (location && location.state && location.state.replyAddress) { setFormData({ address: location.state.replyAddress, value: 5.5, }); } // 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() { + function handleSendXecError(errorObj, oneToManyFlag) { + // Set loading to false here as well, as balance may not change depending on where error occured in try loop + passLoadingStatus(false); + let message; + + if (!errorObj.error && !errorObj.message) { + message = `Transaction failed: no response from ${getRestUrl()}.`; + } else if ( + /Could not communicate with full node or other external service/.test( + errorObj.error, + ) + ) { + message = 'Could not communicate with API. Please try again.'; + } else if ( + errorObj.error && + errorObj.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 = + errorObj.message || errorObj.error || JSON.stringify(errorObj); + } + + if (oneToManyFlag) { + errorNotification(errorObj, message, 'Sending XEC one to many'); + } else { + errorNotification(errorObj, message, 'Sending XEC'); + } + } + + async function send() { setFormData({ ...formData, dirty: false, }); - if ( - !formData.address || - !formData.value || - Number(formData.value) <= 0 - ) { - return; - } - let optionalOpReturnMsg = formData.opReturnMsg; - // Event("Category", "Action", "Label") - // Track number of BCHA send transactions and whether users - // are sending BCHA or USD - Event('Send.js', 'Send', selectedCurrency); + if (isOneToManyXECSend) { + // this is a one to many XEC send transactions + + // ensure multi-recipient input is not blank + if (!formData.address) { + return; + } + + // Event("Category", "Action", "Label") + // Track number of XEC send-to-many transactions + Event('Send.js', 'SendToMany', selectedCurrency); + + passLoadingStatus(true); + const { address, value } = formData; + + //convert each line from TextArea input + let addressAndValueArray = address.split('\n'); + + try { + // construct array of XEC->BCH addresses due to bch-api constraint + let cleanAddressAndValueArray = + toLegacyArray(addressAndValueArray); + + const link = await sendXec( + BCH, + wallet, + slpBalancesAndUtxos.nonSlpUtxos, + currency.defaultFee, + optionalOpReturnMsg, + true, // indicate send mode is one to many + cleanAddressAndValueArray, + ); + sendXecNotification(link); + } catch (e) { + handleSendXecError(e, isOneToManyXECSend); + } + } else { + // standard one to one XEC send transaction + + if ( + !formData.address || + !formData.value || + Number(formData.value) <= 0 + ) { + return; + } - passLoadingStatus(true); - const { address, value } = formData; + // Event("Category", "Action", "Label") + // Track number of BCHA send transactions and whether users + // are sending BCHA or USD + Event('Send.js', 'Send', selectedCurrency); - // Get the param-free address - let cleanAddress = address.split('?')[0]; + passLoadingStatus(true); + const { address, value } = formData; - // Ensure address has bitcoincash: prefix and checksum - cleanAddress = toLegacy(cleanAddress); + // Get the param-free address + let cleanAddress = address.split('?')[0]; - let hasValidCashPrefix; - try { - hasValidCashPrefix = cleanAddress.startsWith( - currency.legacyPrefix + ':', - ); - } catch (err) { - hasValidCashPrefix = false; - console.log(`toLegacy() returned an error:`, cleanAddress); - } + // Ensure address has bitcoincash: prefix and checksum + cleanAddress = toLegacy(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 - passLoadingStatus(false); - setSendBchAddressError( - `Destination is not a valid ${currency.ticker} address`, - ); - return; - } + let hasValidCashPrefix; - // Calculate the amount in BCH - let bchValue = value; + try { + hasValidCashPrefix = cleanAddress.startsWith( + currency.legacyPrefix + ':', + ); + } catch (err) { + hasValidCashPrefix = false; + console.log(`toLegacy() returned an error:`, cleanAddress); + } - if (selectedCurrency !== 'XEC') { - bchValue = fiatToCrypto(value, fiatPrice); - } + 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 + passLoadingStatus(false); + setSendBchAddressError( + `Destination is not a valid ${currency.ticker} address`, + ); + return; + } - try { - const link = await sendBch( - BCH, - wallet, - slpBalancesAndUtxos.nonSlpUtxos, - cleanAddress, - bchValue, - currency.defaultFee, - optionalOpReturnMsg, - ); - sendXecNotification(link); - } catch (e) { - // Set loading to false here as well, as balance may not change depending on where error occured in try loop - passLoadingStatus(false); - let message; + // Calculate the amount in BCH + let bchValue = value; - 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); + if (selectedCurrency !== 'XEC') { + bchValue = fiatToCrypto(value, fiatPrice); } - errorNotification(e, message, 'Sending XEC'); + try { + const link = await sendXec( + BCH, + wallet, + slpBalancesAndUtxos.nonSlpUtxos, + currency.defaultFee, + optionalOpReturnMsg, + false, // sendToMany boolean flag + null, // address array not applicable for one to many tx + cleanAddress, + bchValue, + ); + sendXecNotification(link); + } catch (e) { + handleSendXecError(e, isOneToManyXECSend); + } } } 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 handleMultiAddressChange = e => { + const { value, name } = e.target; + let error; + + if (!value) { + error = 'recipient input must not be blank'; + } + + //convert each line from the input into array + let addressStringArray = value.split('\n'); + const arrayLength = addressStringArray.length; + + // loop through each row in the input + for (let i = 0; i < arrayLength; i++) { + if (addressStringArray[i].trim() === '') { + // if this line is a line break or bunch of spaces + error = 'Empty spaces and rows must be removed'; + break; + } + + let addressString = addressStringArray[i].split(',')[0]; + let valueString = addressStringArray[i].split(',')[1]; + + let addressInfo = parseAddress(BCH, addressString); + + let validation = isValidSendToMany( + addressInfo, + valueString, + currency.ticker, + ); + + if (validation !== true) { + // not using 'if(validation)' since error strings can be interpreted the same + error = validation; + } + if (error) { + // if one line is invalid, break loop and avoid wasting further iterations + break; + } + } + + // no error displayed if isValid returns true, otherwise display error message + if (error) { + setSendBchAddressError(error); + } else { + setSendBchAddressError(false); + } + // 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 handleOpReturnMsgChange = e => { const { value, name } = e.target; setFormData(p => ({ ...p, [name]: value, })); }; + // true: renders the multi recipient + // false: renders the single recipient + const handleOneToManyXECSend = sendXecMode => { + setIsOneToManyXECSend(sendXecMode); + }; + 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 handleSignMsgChange = e => { const { value } = e.target; // validation if (value && value.length && value.length < 150) { setMsgToSign(value); setSignMessageIsValid(true); } else { setSignMessageIsValid(false); } }; const signMessageByPk = async () => { try { const messageSignature = await signPkMessage( BCH, wallet.Path1899.fundingWif, msgToSign, ); setMessageSignature(messageSignature); messageSignedNotification(messageSignature); } catch (err) { let message; if (!err.error && !err.message) { message = err.message || err.error || JSON.stringify(err); } errorNotification(err, message, 'Message Signing Error'); throw err; } // Hide the modal setShowConfirmMsgToSign(false); setSigCopySuccess(''); }; const handleOnSigCopy = () => { if (messageSignature != '') { setSigCopySuccess('Signature copied to clipboard'); } }; 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) { // calculate conversion to fiatPrice fiatPriceString = `${(fiatPrice * Number(formData.value)).toFixed( 2, )}`; // formats to fiat locale style fiatPriceString = formatFiatBalance(Number(fiatPriceString)); // insert symbol and currency before/after the locale formatted fiat balance fiatPriceString = `${ cashtabSettings ? `${ currency.fiatCurrencies[cashtabSettings.fiatCurrency] .symbol } ` : '$ ' } ${fiatPriceString} ${ cashtabSettings && cashtabSettings.fiatCurrency ? cashtabSettings.fiatCurrency.toUpperCase() : 'USD' }`; } else { fiatPriceString = `${ formData.value ? formatFiatBalance( Number(fiatToCrypto(formData.value, fiatPrice)), ) : formatFiatBalance(0) } ${currency.ticker}`; } } 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 ) : ( <> {fiatPrice !== null && ( )} > )} - - handleAddressChange({ - target: { + {!isOneToManyXECSend ? ( + <> + setIsOneToManyXECSend(true)} + > + + Switch to multiple recipients + + + + handleAddressChange({ + target: { + name: 'address', + value: result, + }, + }) + } + inputProps={{ + placeholder: `${currency.ticker} Address`, name: 'address', - value: result, - }, - }) - } - inputProps={{ - placeholder: `${currency.ticker} Address`, - name: 'address', - onChange: e => handleAddressChange(e), - required: true, - value: 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{' '} - {currency.fiatCurrencies[ - cashtabSettings.fiatCurrency - ].slug.toUpperCase()}{' '} - disabled - + onChange: e => handleAddressChange(e), + required: true, + value: 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{' '} + {currency.fiatCurrencies[ + cashtabSettings.fiatCurrency + ].slug.toUpperCase()}{' '} + disabled + + )} + + {fiatPriceString !== '' && '='}{' '} + {fiatPriceString} + + > + ) : ( + <> + setIsOneToManyXECSend(false)} + > + + Switch to a single recipient + + + + handleMultiAddressChange(e), + required: true, + value: formData.address, + }} + > + > )} - - {fiatPriceString !== '' && '='} {fiatPriceString} - Message: handleOpReturnMsgChange(e) } showCount maxLength={160} onKeyDown={e => e.keyCode == 13 ? e.preventDefault() : '' } /> {!balances.totalBalance || apiError || sendBchAmountError || sendBchAddressError ? ( Send ) : ( <> {txInfoFromUrl ? ( showModal()} > Send ) : ( - submit()}> + send()}> Send )} > )} {queryStringText && ( )} {apiError && } Signatures setShowConfirmMsgToSign(false)} > Message: {msgToSign} Message: handleSignMsgChange(e)} showCount maxLength={150} /> Address: setShowConfirmMsgToSign(true)} disabled={!signMessageIsValid} > Sign Message Signature: handleOnSigCopy()} /> {sigCopySuccess} > ); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in Send.test.js status => {console.log(status)} is an arbitrary stub function */ SendBCH.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; SendBCH.propTypes = { jestBCH: PropTypes.object, passLoadingStatus: PropTypes.func, }; 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 f9d8f9840..2447e44f1 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,2199 +1,2254 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP @generated exports[`Wallet with BCH balances 1`] = ` Array [ You currently have 0 XEC Deposit some funds to use this feature , + + + Switch to multiple recipients + + XEC max = $ NaN USD Advanced Send , Signatures , Sign Message , ] `; exports[`Wallet with BCH balances and tokens 1`] = ` Array [ You currently have 0 XEC Deposit some funds to use this feature , + + + Switch to multiple recipients + + XEC max = $ NaN USD Advanced Send , Signatures , Sign Message , ] `; exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [ 0.06 XEC , $ NaN USD , + + + Switch to multiple recipients + + XEC max = $ NaN USD Advanced Send , Signatures , Sign Message , ] `; exports[`Wallet without BCH balance 1`] = ` Array [ You currently have 0 XEC Deposit some funds to use this feature , + + + Switch to multiple recipients + + XEC max = $ NaN USD Advanced Send , Signatures , Sign Message , ] `; exports[`Without wallet defined 1`] = ` Array [ You currently have 0 XEC Deposit some funds to use this feature , + + + Switch to multiple recipients + + XEC max = $ NaN USD Advanced Send , Signatures , Sign Message , ] `; diff --git a/web/cashtab/src/hooks/__mocks__/sendBCH.js b/web/cashtab/src/hooks/__mocks__/sendBCH.js index e6790d682..b4afe46da 100644 --- a/web/cashtab/src/hooks/__mocks__/sendBCH.js +++ b/web/cashtab/src/hooks/__mocks__/sendBCH.js @@ -1,38 +1,38 @@ import { fromSmallestDenomination } from '@utils/cashMethods'; import { currency } from '@components/Common/Ticker'; export default { utxos: [ { height: 0, tx_hash: '6e83b4bf54b5a85b6c40c4e2076a6e3945b86e4d219a931d0eb93ba1a1e3bd6f', tx_pos: 1, value: 131689, address: 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', satoshis: 131689, txid: '6e83b4bf54b5a85b6c40c4e2076a6e3945b86e4d219a931d0eb93ba1a1e3bd6f', vout: 1, isValid: false, wif: 'L3ufcMjHZ2u8v2NeyHB2pCSE5ezCk8dvR7kcLLX2B3xK5VgK9wz4', }, ], wallet: { Path145: { cashAddress: 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', }, Path1899: { cashAddress: 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05', }, }, destinationAddress: 'bitcoincash:qr2npxqwznhp7gphatcqzexeclx0hhwdxg386ez36n', sendAmount: fromSmallestDenomination(currency.dustSats).toString(), expectedTxId: '7a39961bbd7e27d804fb3169ef38a83234710fbc53897a4eb0c98454854a26d1', expectedHex: [ - '02000000016fbde3a1a13bb90e1d939a214d6eb845396e6a07e2c4406c5ba8b554bfb4836e010000006a47304402207f641d1822e2bad12178238155516dc3a00d7aea25ef54fc602cc3390093c6ef022066b0bbbfb931c1c7d3d56b56b51e5ee4e9782ac51a4955bf06e0177533ad0c5c4121032d9ea429b4782e9a2c18a383362c23a44efa2f6d6641d63f53788b4bf45c1decffffffff0226020000000000001976a914d530980e14ee1f2037eaf00164d9c7ccfbddcd3288ac5eff0100000000001976a914c5c649ec64e02a16a5bd7a6c8f1fa5aaa7e488eb88ac00000000', + '02000000016fbde3a1a13bb90e1d939a214d6eb845396e6a07e2c4406c5ba8b554bfb4836e010000006a473044022014213502b672599a965f03a91c4aecb789ed15e758ba6594426572ed2ff20ef202201137053f16b9f1b796076ebe6e4755304f3be5df96bb181aaf9f70ad229291bb4121032d9ea429b4782e9a2c18a383362c23a44efa2f6d6641d63f53788b4bf45c1decffffffff0226020000000000001976a914d530980e14ee1f2037eaf00164d9c7ccfbddcd3288ac7cfe0100000000001976a914c5c649ec64e02a16a5bd7a6c8f1fa5aaa7e488eb88ac00000000', ], }; diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/hooks/__tests__/useBCH.test.js index b0e7beaab..4da183fb1 100644 --- a/web/cashtab/src/hooks/__tests__/useBCH.test.js +++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js @@ -1,417 +1,479 @@ /* eslint-disable no-native-reassign */ import useBCH from '../useBCH'; import mockReturnGetHydratedUtxoDetails from '../__mocks__/mockReturnGetHydratedUtxoDetails'; import mockReturnGetSlpBalancesAndUtxos from '../__mocks__/mockReturnGetSlpBalancesAndUtxos'; import mockReturnGetHydratedUtxoDetailsWithZeroBalance from '../__mocks__/mockReturnGetHydratedUtxoDetailsWithZeroBalance'; import mockReturnGetSlpBalancesAndUtxosNoZeroBalance from '../__mocks__/mockReturnGetSlpBalancesAndUtxosNoZeroBalance'; import sendBCHMock from '../__mocks__/sendBCH'; import createTokenMock from '../__mocks__/createToken'; import mockTxHistory from '../__mocks__/mockTxHistory'; import mockFlatTxHistory from '../__mocks__/mockFlatTxHistory'; import mockTxDataWithPassthrough from '../__mocks__/mockTxDataWithPassthrough'; import { flattenedHydrateUtxosResponse, legacyHydrateUtxosResponse, } from '../__mocks__/mockHydrateUtxosBatched'; import { tokenSendWdt, tokenReceiveGarmonbozia, tokenReceiveTBS, tokenGenesisCashtabMintAlpha, } from '../__mocks__/mockParseTokenInfoForTxHistory'; import { mockSentCashTx, mockReceivedCashTx, mockSentTokenTx, mockReceivedTokenTx, mockSentOpReturnMessageTx, mockReceivedOpReturnMessageTx, } from '../__mocks__/mockParsedTxs'; import BCHJS from '@psf/bch-js'; // TODO: should be removed when external lib not needed anymore import { currency } from '../../components/Common/Ticker'; import BigNumber from 'bignumber.js'; import { fromSmallestDenomination } from '@utils/cashMethods'; describe('useBCH hook', () => { it('gets Rest Api Url on testnet', () => { process = { env: { REACT_APP_NETWORK: `testnet`, REACT_APP_BCHA_APIS: 'https://rest.kingbch.com/v3/,https://wallet-service-prod.bitframe.org/v3/,notevenaurl,https://rest.kingbch.com/v3/', REACT_APP_BCHA_APIS_TEST: 'https://free-test.fullstack.cash/v3/', }, }; const { getRestUrl } = useBCH(); const expectedApiUrl = `https://free-test.fullstack.cash/v3/`; expect(getRestUrl(0)).toBe(expectedApiUrl); }); it('gets primary Rest API URL on mainnet', () => { process = { env: { REACT_APP_BCHA_APIS: 'https://rest.kingbch.com/v3/,https://wallet-service-prod.bitframe.org/v3/,notevenaurl,https://rest.kingbch.com/v3/', REACT_APP_NETWORK: 'mainnet', }, }; const { getRestUrl } = useBCH(); const expectedApiUrl = `https://rest.kingbch.com/v3/`; expect(getRestUrl(0)).toBe(expectedApiUrl); }); it('calculates fee correctly for 2 P2PKH outputs', () => { const { calcFee } = useBCH(); const BCH = new BCHJS(); const utxosMock = [{}, {}]; expect(calcFee(BCH, utxosMock, 2, 1.01)).toBe(378); }); it('gets SLP and BCH balances and utxos from hydrated utxo details', async () => { const { getSlpBalancesAndUtxos } = useBCH(); const BCH = new BCHJS(); const result = await getSlpBalancesAndUtxos( BCH, mockReturnGetHydratedUtxoDetails, ); expect(result).toStrictEqual(mockReturnGetSlpBalancesAndUtxos); }); it(`Ignores SLP utxos with utxo.tokenQty === '0'`, async () => { const { getSlpBalancesAndUtxos } = useBCH(); const BCH = new BCHJS(); const result = await getSlpBalancesAndUtxos( BCH, mockReturnGetHydratedUtxoDetailsWithZeroBalance, ); expect(result).toStrictEqual( mockReturnGetSlpBalancesAndUtxosNoZeroBalance, ); }); it(`Parses flattened batched hydrateUtxosResponse to yield same result as legacy unbatched hydrateUtxosResponse`, async () => { const { getSlpBalancesAndUtxos } = useBCH(); const BCH = new BCHJS(); const batchedResult = await getSlpBalancesAndUtxos( BCH, flattenedHydrateUtxosResponse, ); const legacyResult = await getSlpBalancesAndUtxos( BCH, legacyHydrateUtxosResponse, ); expect(batchedResult).toStrictEqual(legacyResult); }); - it('sends BCH correctly', async () => { - const { sendBch } = useBCH(); + it('sends XEC correctly', async () => { + const { sendXec } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, expectedHex, utxos, wallet, destinationAddress, sendAmount, } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); expect( - await sendBch( + await sendXec( BCH, wallet, utxos, + currency.defaultFee, + '', + false, + null, destinationAddress, sendAmount, - 1.01, ), ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith( expectedHex, ); }); + it('sends one to many XEC correctly', async () => { + const { sendXec } = useBCH(); + const BCH = new BCHJS(); + const { + expectedTxId, + expectedHex, + utxos, + wallet, + destinationAddress, + sendAmount, + } = sendBCHMock; + + const addressAndValueArray = [ + 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6', + 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6.8', + 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,7', + 'bitcoincash:qrzuvj0vvnsz5949h4axercl5k420eygavv0awgz05,6', + ]; + + BCH.RawTransactions.sendRawTransaction = jest + .fn() + .mockResolvedValue(expectedTxId); + expect( + await sendXec( + BCH, + wallet, + utxos, + currency.defaultFee, + '', + true, + addressAndValueArray, + ), + ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`); + }); + it(`Throws error if called trying to send one base unit ${currency.ticker} more than available in utxo set`, async () => { - const { sendBch } = useBCH(); + const { sendXec } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock; const expectedTxFeeInSats = 229; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); const oneBaseUnitMoreThanBalance = new BigNumber(utxos[0].value) .minus(expectedTxFeeInSats) .plus(1) .div(10 ** currency.cashDecimals) .toString(); - const failedSendBch = sendBch( + const failedSendBch = sendXec( BCH, wallet, utxos, + currency.defaultFee, + '', + false, + null, destinationAddress, oneBaseUnitMoreThanBalance, - 1.01, ); expect(failedSendBch).rejects.toThrow(new Error('Insufficient funds')); - const nullValuesSendBch = await sendBch( + const nullValuesSendBch = await sendXec( BCH, wallet, utxos, + currency.defaultFee, + '', + false, + null, destinationAddress, null, - 1.01, ); expect(nullValuesSendBch).toBe(null); }); it('Throws error on attempt to send one satoshi less than backend dust limit', async () => { - const { sendBch } = useBCH(); + const { sendXec } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, utxos, wallet, destinationAddress } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); - const failedSendBch = sendBch( + const failedSendBch = sendXec( BCH, wallet, utxos, + currency.defaultFee, + '', + false, + null, destinationAddress, new BigNumber( fromSmallestDenomination(currency.dustSats).toString(), ) .minus(new BigNumber('0.00000001')) .toString(), - 1.01, ); expect(failedSendBch).rejects.toThrow(new Error('dust')); - const nullValuesSendBch = await sendBch( + const nullValuesSendBch = await sendXec( BCH, wallet, utxos, + currency.defaultFee, + '', + false, + null, destinationAddress, null, - 1.01, ); expect(nullValuesSendBch).toBe(null); }); it('receives errors from the network and parses it', async () => { - const { sendBch } = useBCH(); + const { sendXec } = useBCH(); const BCH = new BCHJS(); const { sendAmount, utxos, wallet, destinationAddress } = sendBCHMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { throw new Error('insufficient priority (code 66)'); }); - const insufficientPriority = sendBch( + const insufficientPriority = sendXec( BCH, wallet, utxos, + currency.defaultFee, + '', + false, + null, destinationAddress, sendAmount, - 1.01, ); await expect(insufficientPriority).rejects.toThrow( new Error('insufficient priority (code 66)'), ); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { throw new Error('txn-mempool-conflict (code 18)'); }); - const txnMempoolConflict = sendBch( + const txnMempoolConflict = sendXec( BCH, wallet, utxos, + currency.defaultFee, + '', + false, + null, destinationAddress, sendAmount, - 1.01, ); await expect(txnMempoolConflict).rejects.toThrow( new Error('txn-mempool-conflict (code 18)'), ); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { throw new Error('Network Error'); }); - const networkError = sendBch( + const networkError = sendXec( BCH, wallet, utxos, + currency.defaultFee, + '', + false, + null, destinationAddress, sendAmount, - 1.01, ); await expect(networkError).rejects.toThrow(new Error('Network Error')); BCH.RawTransactions.sendRawTransaction = jest .fn() .mockImplementation(async () => { const err = new Error( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)', ); throw err; }); - const tooManyAncestorsMempool = sendBch( + const tooManyAncestorsMempool = sendXec( BCH, wallet, utxos, + currency.defaultFee, + '', + false, + null, destinationAddress, sendAmount, - 1.01, ); await expect(tooManyAncestorsMempool).rejects.toThrow( new Error( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)', ), ); }); it('creates a token correctly', async () => { const { createToken } = useBCH(); const BCH = new BCHJS(); const { expectedTxId, expectedHex, wallet, configObj } = createTokenMock; BCH.RawTransactions.sendRawTransaction = jest .fn() .mockResolvedValue(expectedTxId); expect(await createToken(BCH, wallet, 5.01, configObj)).toBe( `${currency.tokenExplorerUrl}/tx/${expectedTxId}`, ); expect(BCH.RawTransactions.sendRawTransaction).toHaveBeenCalledWith( expectedHex, ); }); it('Throws correct error if user attempts to create a token with an invalid wallet', async () => { const { createToken } = useBCH(); const BCH = new BCHJS(); const { invalidWallet, configObj } = createTokenMock; const invalidWalletTokenCreation = createToken( BCH, invalidWallet, currency.defaultFee, configObj, ); await expect(invalidWalletTokenCreation).rejects.toThrow( new Error('Invalid wallet'), ); }); it('Correctly flattens transaction history', () => { const { flattenTransactions } = useBCH(); expect(flattenTransactions(mockTxHistory, 10)).toStrictEqual( mockFlatTxHistory, ); }); it(`Correctly parses a "send ${currency.ticker}" transaction`, () => { const { parseTxData } = useBCH(); expect(parseTxData([mockTxDataWithPassthrough[0]])).toStrictEqual( mockSentCashTx, ); }); it(`Correctly parses a "receive ${currency.ticker}" transaction`, () => { const { parseTxData } = useBCH(); expect(parseTxData([mockTxDataWithPassthrough[5]])).toStrictEqual( mockReceivedCashTx, ); }); it(`Correctly parses a "send ${currency.tokenTicker}" transaction`, () => { const { parseTxData } = useBCH(); expect(parseTxData([mockTxDataWithPassthrough[1]])).toStrictEqual( mockSentTokenTx, ); }); it(`Correctly parses a "receive ${currency.tokenTicker}" transaction`, () => { const { parseTxData } = useBCH(); expect(parseTxData([mockTxDataWithPassthrough[3]])).toStrictEqual( mockReceivedTokenTx, ); }); it(`Correctly parses a "send ${currency.tokenTicker}" transaction with token details`, () => { const { parseTokenInfoForTxHistory } = useBCH(); const BCH = new BCHJS(); expect( parseTokenInfoForTxHistory( BCH, tokenSendWdt.parsedTx, tokenSendWdt.tokenInfo, ), ).toStrictEqual(tokenSendWdt.cashtabTokenInfo); }); it(`Correctly parses a "receive ${currency.tokenTicker}" transaction with token details and 9 decimals of precision`, () => { const { parseTokenInfoForTxHistory } = useBCH(); const BCH = new BCHJS(); expect( parseTokenInfoForTxHistory( BCH, tokenReceiveTBS.parsedTx, tokenReceiveTBS.tokenInfo, ), ).toStrictEqual(tokenReceiveTBS.cashtabTokenInfo); }); it(`Correctly parses a "receive ${currency.tokenTicker}" transaction from an HD wallet (change address different from sending address)`, () => { const { parseTokenInfoForTxHistory } = useBCH(); const BCH = new BCHJS(); expect( parseTokenInfoForTxHistory( BCH, tokenReceiveGarmonbozia.parsedTx, tokenReceiveGarmonbozia.tokenInfo, ), ).toStrictEqual(tokenReceiveGarmonbozia.cashtabTokenInfo); }); it(`Correctly parses a "GENESIS ${currency.tokenTicker}" transaction with token details`, () => { const { parseTokenInfoForTxHistory } = useBCH(); const BCH = new BCHJS(); expect( parseTokenInfoForTxHistory( BCH, tokenGenesisCashtabMintAlpha.parsedTx, tokenGenesisCashtabMintAlpha.tokenInfo, ), ).toStrictEqual(tokenGenesisCashtabMintAlpha.cashtabTokenInfo); }); it(`Correctly parses a "send ${currency.ticker}" transaction with an OP_RETURN message`, () => { const { parseTxData } = useBCH(); expect(parseTxData([mockTxDataWithPassthrough[10]])).toStrictEqual( mockSentOpReturnMessageTx, ); }); it(`Correctly parses a "receive ${currency.ticker}" transaction with an OP_RETURN message`, () => { const { parseTxData } = useBCH(); expect(parseTxData([mockTxDataWithPassthrough[11]])).toStrictEqual( mockReceivedOpReturnMessageTx, ); }); }); diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js index 57f68c5e7..24322ac01 100644 --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -1,1162 +1,1222 @@ import BigNumber from 'bignumber.js'; import { currency, isCashtabOutput, isEtokenOutput, extractCashtabMessage, extractExternalMessage, } from '@components/Common/Ticker'; import { isValidTokenStats } from '@utils/validation'; import SlpWallet from 'minimal-slp-wallet'; import { toSmallestDenomination, fromSmallestDenomination, batchArray, flattenBatchedHydratedUtxos, isValidStoredWallet, checkNullUtxosForTokenStatus, confirmNonEtokenUtxos, } from '@utils/cashMethods'; import cashaddr from 'ecashaddrjs'; export default function useBCH() { const SEND_BCH_ERRORS = { INSUFFICIENT_FUNDS: 0, NETWORK_ERROR: 1, INSUFFICIENT_PRIORITY: 66, // ~insufficient fee DOUBLE_SPENDING: 18, MAX_UNCONFIRMED_TXS: 64, }; const getRestUrl = (apiIndex = 0) => { const apiString = process.env.REACT_APP_NETWORK === `mainnet` ? process.env.REACT_APP_BCHA_APIS : process.env.REACT_APP_BCHA_APIS_TEST; const apiArray = apiString.split(','); return apiArray[apiIndex]; }; const flattenTransactions = ( txHistory, txCount = currency.txHistoryCount, ) => { /* Convert txHistory, format [{address: '', transactions: [{height: '', tx_hash: ''}, ...{}]}, {}, {}] to flatTxHistory [{txid: '', blockheight: '', address: ''}] sorted by blockheight, newest transactions to oldest transactions */ let flatTxHistory = []; let includedTxids = []; for (let i = 0; i < txHistory.length; i += 1) { const { address, transactions } = txHistory[i]; for (let j = transactions.length - 1; j >= 0; j -= 1) { let flatTx = {}; flatTx.address = address; // If tx is unconfirmed, give arbitrarily high blockheight flatTx.height = transactions[j].height <= 0 ? 10000000 : transactions[j].height; flatTx.txid = transactions[j].tx_hash; // Only add this tx if the same transaction is not already in the array // This edge case can happen with older wallets, txs can be on multiple paths if (!includedTxids.includes(flatTx.txid)) { includedTxids.push(flatTx.txid); flatTxHistory.push(flatTx); } } } // Sort with most recent transaction at index 0 flatTxHistory.sort((a, b) => b.height - a.height); // Only return 10 return flatTxHistory.splice(0, txCount); }; const parseTxData = txData => { /* Desired output [ { txid: '', type: send, receive receivingAddress: '', quantity: amount bcha token: true/false tokenInfo: { tokenId: tokenQty: txType: mint, send, other } opReturnMessage: 'message extracted from asm' or '' } ] */ const parsedTxHistory = []; for (let i = 0; i < txData.length; i += 1) { const tx = txData[i]; const parsedTx = {}; // Move over info that does not need to be calculated parsedTx.txid = tx.txid; parsedTx.height = tx.height; let destinationAddress = tx.address; // If this tx had too many inputs to be parsed by getTxDataWithPassThrough, skip it // When this occurs, the tx will only have txid and height // So, it will not have 'vin' if (!Object.keys(tx).includes('vin')) { // Populate as a limited-info tx that can be expanded in a block explorer parsedTxHistory.push(parsedTx); continue; } parsedTx.confirmations = tx.confirmations; parsedTx.blocktime = tx.blocktime; let amountSent = 0; let amountReceived = 0; let opReturnMessage = ''; let isCashtabMessage = false; // Assume an incoming transaction let outgoingTx = false; let tokenTx = false; let substring = ''; // get the address of the sender for this tx and encode into eCash address let senderBchAddress = tx.vin[0].address; const { prefix, type, hash } = cashaddr.decode(senderBchAddress); const senderAddress = cashaddr.encode('ecash', type, hash); // If vin includes tx address, this is an outgoing tx // Note that with bch-input data, we do not have input amounts for (let j = 0; j < tx.vin.length; j += 1) { const thisInput = tx.vin[j]; if (thisInput.address === tx.address) { // This is an outgoing transaction outgoingTx = true; } } // Iterate over vout to find how much was sent or received for (let j = 0; j < tx.vout.length; j += 1) { const thisOutput = tx.vout[j]; // If there is no addresses object in the output, it's either an OP_RETURN msg or token tx if ( !Object.keys(thisOutput.scriptPubKey).includes('addresses') ) { let hex = thisOutput.scriptPubKey.hex; if (isEtokenOutput(hex)) { // this is an eToken transaction tokenTx = true; } else if (isCashtabOutput(hex)) { // this is a cashtab.com generated message try { substring = extractCashtabMessage(hex); opReturnMessage = Buffer.from(substring, 'hex'); isCashtabMessage = true; } catch (err) { // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; console.log( 'useBCH.parsedTxHistory() error: invalid cashtab msg hex: ' + substring, ); } } else { // this is an externally generated message try { substring = extractExternalMessage(hex); opReturnMessage = Buffer.from(substring, 'hex'); } catch (err) { // soft error if an unexpected or invalid cashtab hex is encountered opReturnMessage = ''; console.log( 'useBCH.parsedTxHistory() error: invalid external msg hex: ' + substring, ); } } continue; // skipping the remainder of tx data parsing logic in both token and OP_RETURN tx cases } if ( thisOutput.scriptPubKey.addresses && thisOutput.scriptPubKey.addresses[0] === tx.address ) { if (outgoingTx) { // This amount is change continue; } amountReceived += thisOutput.value; } else if (outgoingTx) { amountSent += thisOutput.value; // Assume there's only one destination address, i.e. it was sent by a Cashtab wallet destinationAddress = thisOutput.scriptPubKey.addresses[0]; } } // Construct parsedTx parsedTx.amountSent = amountSent; parsedTx.amountReceived = amountReceived; parsedTx.tokenTx = tokenTx; parsedTx.outgoingTx = outgoingTx; parsedTx.replyAddress = senderAddress; parsedTx.destinationAddress = destinationAddress; parsedTx.opReturnMessage = opReturnMessage; parsedTx.isCashtabMessage = isCashtabMessage; parsedTxHistory.push(parsedTx); } return parsedTxHistory; }; const getTxHistory = async (BCH, addresses) => { let txHistoryResponse; try { //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); //console.log(addresses); txHistoryResponse = await BCH.Electrumx.transactions(addresses); //console.log(`BCH.Electrumx.transactions(addresses) succeeded`); //console.log(`txHistoryResponse`, txHistoryResponse); if (txHistoryResponse.success && txHistoryResponse.transactions) { return txHistoryResponse.transactions; } else { // eslint-disable-next-line no-throw-literal throw new Error('Error in getTxHistory'); } } catch (err) { console.log(`Error in BCH.Electrumx.transactions(addresses):`); console.log(err); return err; } }; const getTxDataWithPassThrough = async (BCH, flatTx) => { // necessary as BCH.RawTransactions.getTxData does not return address or blockheight let txDataWithPassThrough = {}; try { txDataWithPassThrough = await BCH.RawTransactions.getTxData( flatTx.txid, ); } catch (err) { console.log( `Error in BCH.RawTransactions.getTxData(${flatTx.txid})`, ); console.log(err); // Include txid if you don't get it from the attempted response txDataWithPassThrough.txid = flatTx.txid; } txDataWithPassThrough.height = flatTx.height; txDataWithPassThrough.address = flatTx.address; return txDataWithPassThrough; }; const getTxData = async (BCH, txHistory) => { // Flatten tx history let flatTxs = flattenTransactions(txHistory); // Build array of promises to get tx data for all 10 transactions let txDataPromises = []; for (let i = 0; i < flatTxs.length; i += 1) { const txDataPromise = await getTxDataWithPassThrough( BCH, flatTxs[i], ); txDataPromises.push(txDataPromise); } // Get txData for the 10 most recent transactions let txDataPromiseResponse; try { txDataPromiseResponse = await Promise.all(txDataPromises); const parsed = parseTxData(txDataPromiseResponse); return parsed; } catch (err) { console.log(`Error in Promise.all(txDataPromises):`); console.log(err); return err; } }; const parseTokenInfoForTxHistory = (BCH, parsedTx, tokenInfo) => { // Address at which the eToken was received const { destinationAddress } = parsedTx; // Here in cashtab, destinationAddress is in bitcoincash: format // In the API response of tokenInfo, this will be in simpleledger: format // So, must convert to simpleledger const receivingSlpAddress = BCH.SLP.Address.toSLPAddress(destinationAddress); const { transactionType, sendInputsFull, sendOutputsFull } = tokenInfo; const sendingTokenAddresses = []; // Scan over inputs to find out originating addresses for (let i = 0; i < sendInputsFull.length; i += 1) { const sendingAddress = sendInputsFull[i].address; sendingTokenAddresses.push(sendingAddress); } // Scan over outputs to find out how much was sent let qtySent = new BigNumber(0); let qtyReceived = new BigNumber(0); for (let i = 0; i < sendOutputsFull.length; i += 1) { if (sendingTokenAddresses.includes(sendOutputsFull[i].address)) { // token change and should be ignored, unless it's a genesis transaction // then this is the amount created if (transactionType === 'GENESIS') { qtyReceived = qtyReceived.plus( new BigNumber(sendOutputsFull[i].amount), ); } continue; } if (parsedTx.outgoingTx) { qtySent = qtySent.plus( new BigNumber(sendOutputsFull[i].amount), ); } else { // Only if this matches the receiving address if (sendOutputsFull[i].address === receivingSlpAddress) { qtyReceived = qtyReceived.plus( new BigNumber(sendOutputsFull[i].amount), ); } } } const cashtabTokenInfo = {}; cashtabTokenInfo.qtySent = qtySent.toString(); cashtabTokenInfo.qtyReceived = qtyReceived.toString(); cashtabTokenInfo.tokenId = tokenInfo.tokenIdHex; cashtabTokenInfo.tokenName = tokenInfo.tokenName; cashtabTokenInfo.tokenTicker = tokenInfo.tokenTicker; cashtabTokenInfo.transactionType = transactionType; return cashtabTokenInfo; }; const addTokenTxDataToSingleTx = async (BCH, parsedTx) => { // Accept one parsedTx // If it's not a token tx, just return it as is and do not parse for token data if (!parsedTx.tokenTx) { return parsedTx; } // If it could be a token tx, do an API call to get token info and return it let tokenData; try { tokenData = await BCH.SLP.Utils.txDetails(parsedTx.txid); } catch (err) { console.log( `Error in parsing BCH.SLP.Utils.txDetails(${parsedTx.txid})`, ); console.log(err); // This is not a token tx parsedTx.tokenTx = false; return parsedTx; } const { tokenInfo } = tokenData; parsedTx.tokenInfo = parseTokenInfoForTxHistory( BCH, parsedTx, tokenInfo, ); return parsedTx; }; const addTokenTxData = async (BCH, parsedTxs) => { // Collect all txids for token transactions into array of promises // Promise.all to get their tx history // Add a tokeninfo object to parsedTxs for token txs // Get txData for the 10 most recent transactions // Build array of promises to get tx data for all 10 transactions let tokenTxDataPromises = []; for (let i = 0; i < parsedTxs.length; i += 1) { const txDataPromise = await addTokenTxDataToSingleTx( BCH, parsedTxs[i], ); tokenTxDataPromises.push(txDataPromise); } let tokenTxDataPromiseResponse; try { tokenTxDataPromiseResponse = await Promise.all(tokenTxDataPromises); return tokenTxDataPromiseResponse; } catch (err) { console.log(`Error in Promise.all(tokenTxDataPromises):`); console.log(err); return err; } }; // Split out the BCH.Electrumx.utxo(addresses) call from the getSlpBalancesandUtxos function // If utxo set has not changed, you do not need to hydrate the utxo set // This drastically reduces calls to the API const getUtxos = async (BCH, addresses) => { let utxosResponse; try { //console.log(`API Call: BCH.Electrumx.utxo(addresses)`); //console.log(addresses); utxosResponse = await BCH.Electrumx.utxo(addresses); //console.log(`BCH.Electrumx.utxo(addresses) succeeded`); //console.log(`utxosResponse`, utxosResponse); return utxosResponse.utxos; } catch (err) { console.log(`Error in BCH.Electrumx.utxo(addresses):`); return err; } }; const getHydratedUtxoDetails = async (BCH, utxos) => { const hydrateUtxosPromises = []; for (let i = 0; i < utxos.length; i += 1) { let thisAddress = utxos[i].address; let theseUtxos = utxos[i].utxos; const batchedUtxos = batchArray( theseUtxos, currency.hydrateUtxoBatchSize, ); // Iterate over each utxo in this address field for (let j = 0; j < batchedUtxos.length; j += 1) { const utxoSetForThisPromise = [ { utxos: batchedUtxos[j], address: thisAddress }, ]; const thisPromise = BCH.SLP.Utils.hydrateUtxos( utxoSetForThisPromise, ); hydrateUtxosPromises.push(thisPromise); } } let hydratedUtxoDetails; try { hydratedUtxoDetails = await Promise.all(hydrateUtxosPromises); const flattenedBatchedHydratedUtxos = flattenBatchedHydratedUtxos(hydratedUtxoDetails); return flattenedBatchedHydratedUtxos; } catch (err) { console.log(`Error in Promise.all(hydrateUtxosPromises)`); console.log(err); return err; } }; const fetchTxDataForNullUtxos = async (BCH, nullUtxos) => { // Check nullUtxos. If they aren't eToken txs, count them console.log( `Null utxos found, checking OP_RETURN fields to confirm they are not eToken txs.`, ); const txids = []; for (let i = 0; i < nullUtxos.length; i += 1) { // Batch API call to get their OP_RETURN asm info txids.push(nullUtxos[i].tx_hash); } let nullUtxoTxData; try { nullUtxoTxData = await BCH.Electrumx.txData(txids); console.log(`nullUtxoTxData`, nullUtxoTxData.transactions); // Scan tx data for each utxo to confirm they are not eToken txs const txDataResults = nullUtxoTxData.transactions; const nonEtokenUtxos = checkNullUtxosForTokenStatus(txDataResults); return nonEtokenUtxos; } catch (err) { console.log(`Error in checkNullUtxosForTokenStatus(nullUtxos)`); console.log(`nullUtxos`, nullUtxos); // If error, ignore these utxos, will be updated next utxo set refresh return []; } }; const getSlpBalancesAndUtxos = async (BCH, hydratedUtxoDetails) => { let hydratedUtxos = []; for (let i = 0; i < hydratedUtxoDetails.slpUtxos.length; i += 1) { const hydratedUtxosAtAddress = hydratedUtxoDetails.slpUtxos[i]; for (let j = 0; j < hydratedUtxosAtAddress.utxos.length; j += 1) { const hydratedUtxo = hydratedUtxosAtAddress.utxos[j]; hydratedUtxo.address = hydratedUtxosAtAddress.address; hydratedUtxos.push(hydratedUtxo); } } //console.log(`hydratedUtxos`, hydratedUtxos); // WARNING // If you hit rate limits, your above utxos object will come back with `isValid` as null, but otherwise ok // You need to throw an error before setting nonSlpUtxos and slpUtxos in this case const nullUtxos = hydratedUtxos.filter(utxo => utxo.isValid === null); if (nullUtxos.length > 0) { console.log(`${nullUtxos.length} null utxos found!`); console.log('nullUtxos', nullUtxos); const nullNonEtokenUtxos = await fetchTxDataForNullUtxos( BCH, nullUtxos, ); // Set isValid === false for nullUtxos that are confirmed non-eToken hydratedUtxos = confirmNonEtokenUtxos( hydratedUtxos, nullNonEtokenUtxos, ); } // Prevent app from treating slpUtxos as nonSlpUtxos // Must enforce === false as api will occasionally return utxo.isValid === null // Do not classify any utxos that include token information as nonSlpUtxos const nonSlpUtxos = hydratedUtxos.filter( utxo => utxo.isValid === false && utxo.value !== currency.etokenSats && !utxo.tokenName, ); // To be included in slpUtxos, the utxo must // have utxo.isValid = true // If utxo has a utxo.tokenQty field, i.e. not a minting baton, then utxo.tokenQty !== '0' const slpUtxos = hydratedUtxos.filter( utxo => utxo.isValid && !(utxo.tokenQty === '0'), ); let tokensById = {}; slpUtxos.forEach(slpUtxo => { let token = tokensById[slpUtxo.tokenId]; if (token) { // Minting baton does nto have a slpUtxo.tokenQty type if (slpUtxo.tokenQty) { token.balance = token.balance.plus( new BigNumber(slpUtxo.tokenQty), ); } //token.hasBaton = slpUtxo.transactionType === "genesis"; if (slpUtxo.utxoType && !token.hasBaton) { token.hasBaton = slpUtxo.utxoType === 'minting-baton'; } // Examples of slpUtxo /* Genesis transaction: { address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" decimals: 9 height: 617564 isValid: true satoshis: 546 tokenDocumentHash: "" tokenDocumentUrl: "developer.bitcoin.com" tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" tokenName: "PiticoLaunch" tokenTicker: "PTCL" tokenType: 1 tx_hash: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" tx_pos: 2 txid: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" utxoType: "minting-baton" value: 546 vout: 2 } Send transaction: { address: "bitcoincash:qrhzv5t79e2afc3rdutcu0d3q20gl7ul3ue58whah6" decimals: 9 height: 655115 isValid: true satoshis: 546 tokenDocumentHash: "" tokenDocumentUrl: "developer.bitcoin.com" tokenId: "6c41f244676ecfcbe3b4fabee2c72c2dadf8d74f8849afabc8a549157db69199" tokenName: "PiticoLaunch" tokenQty: 1.123456789 tokenTicker: "PTCL" tokenType: 1 transactionType: "send" tx_hash: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" tx_pos: 1 txid: "dea400f963bc9f51e010f88533010f8d1f82fc2bcc485ff8500c3a82b25abd9e" utxoType: "token" value: 546 vout: 1 } */ } else { token = {}; token.info = slpUtxo; token.tokenId = slpUtxo.tokenId; if (slpUtxo.tokenQty) { token.balance = new BigNumber(slpUtxo.tokenQty); } else { token.balance = new BigNumber(0); } if (slpUtxo.utxoType) { token.hasBaton = slpUtxo.utxoType === 'minting-baton'; } else { token.hasBaton = false; } tokensById[slpUtxo.tokenId] = token; } }); const tokens = Object.values(tokensById); // console.log(`tokens`, tokens); return { tokens, nonSlpUtxos, slpUtxos, }; }; const calcFee = ( BCH, utxos, p2pkhOutputNumber = 2, satoshisPerByte = currency.defaultFee, ) => { const byteCount = BCH.BitcoinCash.getByteCount( { P2PKH: utxos.length }, { P2PKH: p2pkhOutputNumber }, ); const txFee = Math.ceil(satoshisPerByte * byteCount); return txFee; }; const createToken = async (BCH, wallet, feeInSatsPerByte, configObj) => { try { // Throw error if wallet does not have utxo set in state if (!isValidStoredWallet(wallet)) { const walletError = new Error(`Invalid wallet`); throw walletError; } const utxos = wallet.state.slpBalancesAndUtxos.nonSlpUtxos; const CREATION_ADDR = wallet.Path1899.cashAddress; const inputUtxos = []; let transactionBuilder; // instance of transaction builder if (process.env.REACT_APP_NETWORK === `mainnet`) transactionBuilder = new BCH.TransactionBuilder(); else transactionBuilder = new BCH.TransactionBuilder('testnet'); let originalAmount = new BigNumber(0); let txFee = 0; for (let i = 0; i < utxos.length; i++) { const utxo = utxos[i]; originalAmount = originalAmount.plus(new BigNumber(utxo.value)); const vout = utxo.vout; const txid = utxo.txid; // add input with txid and index of vout transactionBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = calcFee(BCH, inputUtxos, 3, feeInSatsPerByte); if ( originalAmount .minus(new BigNumber(currency.etokenSats)) .minus(new BigNumber(txFee)) .gte(0) ) { break; } } // amount to send back to the remainder address. const remainder = originalAmount .minus(new BigNumber(currency.etokenSats)) .minus(new BigNumber(txFee)); if (remainder.lt(0)) { const error = new Error(`Insufficient funds`); error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; throw error; } // Generate the OP_RETURN entry for an SLP GENESIS transaction. const script = BCH.SLP.TokenType1.generateGenesisOpReturn(configObj); // OP_RETURN needs to be the first output in the transaction. transactionBuilder.addOutput(script, 0); // add output w/ address and amount to send transactionBuilder.addOutput(CREATION_ADDR, currency.etokenSats); // Send change to own address if (remainder.gte(new BigNumber(currency.etokenSats))) { transactionBuilder.addOutput( CREATION_ADDR, parseInt(remainder), ); } // Sign the transactions with the HD node. for (let i = 0; i < inputUtxos.length; i++) { const utxo = inputUtxos[i]; transactionBuilder.sign( i, BCH.ECPair.fromWIF(utxo.wif), undefined, transactionBuilder.hashTypes.SIGHASH_ALL, utxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // Broadcast transaction to the network const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.ticker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.tokenExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; } catch (err) { if (err.error === 'insufficient priority (code 66)') { err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY; } else if (err.error === 'txn-mempool-conflict (code 18)') { err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING; } else if (err.error === 'Network Error') { err.code = SEND_BCH_ERRORS.NETWORK_ERROR; } else if ( err.error === 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' ) { err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS; } console.log(`error: `, err); throw err; } }; // No unit tests for this function as it is only an API wrapper // Return false if do not get a valid response const getTokenStats = async (BCH, tokenId) => { let tokenStats; try { tokenStats = await BCH.SLP.Utils.tokenStats(tokenId); if (isValidTokenStats(tokenStats)) { return tokenStats; } } catch (err) { console.log(`Error fetching token stats for tokenId ${tokenId}`); console.log(err); return false; } }; const sendToken = async ( BCH, wallet, slpBalancesAndUtxos, { tokenId, amount, tokenReceiverAddress }, ) => { // Handle error of user having no BCH if (slpBalancesAndUtxos.nonSlpUtxos.length === 0) { throw new Error( `You need some ${currency.ticker} to send ${currency.tokenTicker}`, ); } const largestBchUtxo = slpBalancesAndUtxos.nonSlpUtxos.reduce( (previous, current) => previous.value > current.value ? previous : current, ); const bchECPair = BCH.ECPair.fromWIF(largestBchUtxo.wif); const tokenUtxos = slpBalancesAndUtxos.slpUtxos.filter( (utxo, index) => { if ( utxo && // UTXO is associated with a token. utxo.tokenId === tokenId && // UTXO matches the token ID. utxo.utxoType === 'token' // UTXO is not a minting baton. ) { return true; } return false; }, ); if (tokenUtxos.length === 0) { throw new Error( 'No token UTXOs for the specified token could be found.', ); } // BEGIN transaction construction. // instance of transaction builder let transactionBuilder; if (process.env.REACT_APP_NETWORK === 'mainnet') { transactionBuilder = new BCH.TransactionBuilder(); } else transactionBuilder = new BCH.TransactionBuilder('testnet'); const originalAmount = largestBchUtxo.value; transactionBuilder.addInput( largestBchUtxo.tx_hash, largestBchUtxo.tx_pos, ); let finalTokenAmountSent = new BigNumber(0); let tokenAmountBeingSentToAddress = new BigNumber(amount); let tokenUtxosBeingSpent = []; for (let i = 0; i < tokenUtxos.length; i++) { finalTokenAmountSent = finalTokenAmountSent.plus( new BigNumber(tokenUtxos[i].tokenQty), ); transactionBuilder.addInput( tokenUtxos[i].tx_hash, tokenUtxos[i].tx_pos, ); tokenUtxosBeingSpent.push(tokenUtxos[i]); if (tokenAmountBeingSentToAddress.lte(finalTokenAmountSent)) { break; } } const slpSendObj = BCH.SLP.TokenType1.generateSendOpReturn( tokenUtxosBeingSpent, tokenAmountBeingSentToAddress.toString(), ); const slpData = slpSendObj.script; // Add OP_RETURN as first output. transactionBuilder.addOutput(slpData, 0); // Send dust transaction representing tokens being sent. transactionBuilder.addOutput( BCH.SLP.Address.toLegacyAddress(tokenReceiverAddress), currency.etokenSats, ); // Return any token change back to the sender. if (slpSendObj.outputs > 1) { // Change goes back to where slp utxo came from transactionBuilder.addOutput( BCH.SLP.Address.toLegacyAddress( tokenUtxosBeingSpent[0].address, ), currency.etokenSats, ); } // get byte count to calculate fee. paying 1 sat // Note: This may not be totally accurate. Just guessing on the byteCount size. const txFee = calcFee( BCH, tokenUtxosBeingSpent, 5, 1.1 * currency.defaultFee, ); // amount to send back to the sending address. It's the original amount - 1 sat/byte for tx size const remainder = originalAmount - txFee - currency.etokenSats * 2; if (remainder < 1) { throw new Error('Selected UTXO does not have enough satoshis'); } // Last output: send the BCH change back to the wallet. // Send it back from whence it came transactionBuilder.addOutput( BCH.Address.toLegacyAddress(largestBchUtxo.address), remainder, ); // Sign the transaction with the private key for the BCH UTXO paying the fees. let redeemScript; transactionBuilder.sign( 0, bchECPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, originalAmount, ); // Sign each token UTXO being consumed. for (let i = 0; i < tokenUtxosBeingSpent.length; i++) { const thisUtxo = tokenUtxosBeingSpent[i]; const accounts = [wallet.Path245, wallet.Path145, wallet.Path1899]; const utxoEcPair = BCH.ECPair.fromWIF( accounts .filter(acc => acc.cashAddress === thisUtxo.address) .pop().fundingWif, ); transactionBuilder.sign( 1 + i, utxoEcPair, redeemScript, transactionBuilder.hashTypes.SIGHASH_ALL, thisUtxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // console.log(`Transaction raw hex: `, hex); // END transaction construction. const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.tokenTicker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.blockExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; }; const signPkMessage = async (BCH, pk, message) => { try { let signature = await BCH.BitcoinCash.signMessageWithPrivKey( pk, message, ); return signature; } catch (err) { console.log(`useBCH.signPkMessage() error: `, err); throw err; } }; - const sendBch = async ( + const sendXec = async ( BCH, wallet, utxos, - destinationAddress, - sendAmount, feeInSatsPerByte, optionalOpReturnMsg, + isOneToMany, + destinationAddressAndValueArray, + destinationAddress, + sendAmount, ) => { try { - if (!sendAmount) { - return null; - } + let value = new BigNumber(0); - const value = new BigNumber(sendAmount); + if (isOneToMany) { + // this is a one to many XEC transaction + if ( + !destinationAddressAndValueArray || + !destinationAddressAndValueArray.length + ) { + throw new Error('Invalid destinationAddressAndValueArray'); + } + const arrayLength = destinationAddressAndValueArray.length; + for (let i = 0; i < arrayLength; i++) { + // add the total value being sent in this array of recipients + value = BigNumber.sum( + value, + new BigNumber( + destinationAddressAndValueArray[i].split(',')[1], + ), + ); + } - // If user is attempting to send less than minimum accepted by the backend - if ( - value.lt( - new BigNumber( - fromSmallestDenomination(currency.dustSats).toString(), - ), - ) - ) { - // Throw the same error given by the backend attempting to broadcast such a tx - throw new Error('dust'); + // If user is attempting to send an aggregate value that is less than minimum accepted by the backend + if ( + value.lt( + new BigNumber( + fromSmallestDenomination( + currency.dustSats, + ).toString(), + ), + ) + ) { + // Throw the same error given by the backend attempting to broadcast such a tx + throw new Error('dust'); + } + } else { + // this is a one to one XEC transaction then check sendAmount + // note: one to many transactions won't be sending a single sendAmount + + if (!sendAmount) { + return null; + } + + value = new BigNumber(sendAmount); + + // If user is attempting to send less than minimum accepted by the backend + if ( + value.lt( + new BigNumber( + fromSmallestDenomination( + currency.dustSats, + ).toString(), + ), + ) + ) { + // Throw the same error given by the backend attempting to broadcast such a tx + throw new Error('dust'); + } } const inputUtxos = []; let transactionBuilder; // instance of transaction builder if (process.env.REACT_APP_NETWORK === `mainnet`) transactionBuilder = new BCH.TransactionBuilder(); else transactionBuilder = new BCH.TransactionBuilder('testnet'); const satoshisToSend = toSmallestDenomination(value); // Throw validation error if toSmallestDenomination returns false if (!satoshisToSend) { const error = new Error( `Invalid decimal places for send amount`, ); throw error; } // Start of building the OP_RETURN output. // only build the OP_RETURN output if the user supplied it if ( typeof optionalOpReturnMsg !== 'undefined' && optionalOpReturnMsg.trim() !== '' ) { const script = [ BCH.Script.opcodes.OP_RETURN, // 6a Buffer.from( currency.opReturn.appPrefixesHex.cashtab, 'hex', ), // 00746162 Buffer.from(optionalOpReturnMsg), ]; const data = BCH.Script.encode(script); transactionBuilder.addOutput(data, 0); } // End of building the OP_RETURN output. let originalAmount = new BigNumber(0); let txFee = 0; for (let i = 0; i < utxos.length; i++) { const utxo = utxos[i]; originalAmount = originalAmount.plus(utxo.value); const vout = utxo.vout; const txid = utxo.txid; // add input with txid and index of vout transactionBuilder.addInput(txid, vout); inputUtxos.push(utxo); txFee = calcFee(BCH, inputUtxos, 2, feeInSatsPerByte); if (originalAmount.minus(satoshisToSend).minus(txFee).gte(0)) { break; } } // Get change address from sending utxos // fall back to what is stored in wallet let REMAINDER_ADDR; // Validate address let isValidChangeAddress; try { REMAINDER_ADDR = inputUtxos[0].address; isValidChangeAddress = BCH.Address.isCashAddress(REMAINDER_ADDR); } catch (err) { isValidChangeAddress = false; } if (!isValidChangeAddress) { REMAINDER_ADDR = wallet.Path1899.cashAddress; } // amount to send back to the remainder address. const remainder = originalAmount.minus(satoshisToSend).minus(txFee); if (remainder.lt(0)) { const error = new Error(`Insufficient funds`); error.code = SEND_BCH_ERRORS.INSUFFICIENT_FUNDS; throw error; } - // add output w/ address and amount to send - transactionBuilder.addOutput( - BCH.Address.toCashAddress(destinationAddress), - parseInt(toSmallestDenomination(value)), - ); + if (isOneToMany) { + // for one to many mode, add the multiple outputs from the array + let arrayLength = destinationAddressAndValueArray.length; + for (let i = 0; i < arrayLength; i++) { + // add each send tx from the array as an output + let outputAddress = + destinationAddressAndValueArray[i].split(',')[0]; + let outputValue = new BigNumber( + destinationAddressAndValueArray[i].split(',')[1], + ); + transactionBuilder.addOutput( + BCH.Address.toCashAddress(outputAddress), + parseInt(toSmallestDenomination(outputValue)), + ); + } + } else { + // for one to one mode, add output w/ single address and amount to send + transactionBuilder.addOutput( + BCH.Address.toCashAddress(destinationAddress), + parseInt(toSmallestDenomination(value)), + ); + } if (remainder.gte(new BigNumber(currency.dustSats))) { transactionBuilder.addOutput( REMAINDER_ADDR, parseInt(remainder), ); } // Sign the transactions with the HD node. for (let i = 0; i < inputUtxos.length; i++) { const utxo = inputUtxos[i]; transactionBuilder.sign( i, BCH.ECPair.fromWIF(utxo.wif), undefined, transactionBuilder.hashTypes.SIGHASH_ALL, utxo.value, ); } // build tx const tx = transactionBuilder.build(); // output rawhex const hex = tx.toHex(); // Broadcast transaction to the network const txidStr = await BCH.RawTransactions.sendRawTransaction([hex]); if (txidStr && txidStr[0]) { console.log(`${currency.ticker} txid`, txidStr[0]); } let link; if (process.env.REACT_APP_NETWORK === `mainnet`) { link = `${currency.blockExplorerUrl}/tx/${txidStr}`; } else { link = `${currency.blockExplorerUrlTestnet}/tx/${txidStr}`; } //console.log(`link`, link); return link; } catch (err) { if (err.error === 'insufficient priority (code 66)') { err.code = SEND_BCH_ERRORS.INSUFFICIENT_PRIORITY; } else if (err.error === 'txn-mempool-conflict (code 18)') { err.code = SEND_BCH_ERRORS.DOUBLE_SPENDING; } else if (err.error === 'Network Error') { err.code = SEND_BCH_ERRORS.NETWORK_ERROR; } else if ( err.error === 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 25] (code 64)' ) { err.code = SEND_BCH_ERRORS.MAX_UNCONFIRMED_TXS; } console.log(`error: `, err); throw err; } }; const getBCH = (apiIndex = 0) => { let ConstructedSlpWallet; ConstructedSlpWallet = new SlpWallet('', { restURL: getRestUrl(apiIndex), }); return ConstructedSlpWallet.bchjs; }; return { getBCH, calcFee, getUtxos, getHydratedUtxoDetails, getSlpBalancesAndUtxos, getTxHistory, flattenTransactions, parseTxData, addTokenTxData, parseTokenInfoForTxHistory, getTxData, getRestUrl, signPkMessage, - sendBch, + sendXec, sendToken, createToken, getTokenStats, }; } diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js index 352bf76c5..deaaf312e 100644 --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -1,284 +1,364 @@ import { shouldRejectAmountInput, fiatToCrypto, isValidTokenName, isValidTokenTicker, isValidTokenDecimals, isValidTokenInitialQty, isValidTokenDocumentUrl, isValidTokenStats, isValidCashtabSettings, formatSavedBalance, formatFiatBalance, + isValidSendToMany, } 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: 'xau' })).toBe(false); }); it(`Rejects a corrupted cashtab settings object for an unsupported currency`, () => { expect(isValidCashtabSettings({ fiatCurrencyWrongLabel: 'usd' })).toBe( false, ); }); it(`test formatSavedBalance with zero XEC balance input`, () => { expect(formatSavedBalance('0', 'en-US')).toBe('0'); }); it(`test formatSavedBalance with a small XEC balance input with 2+ decimal figures`, () => { expect(formatSavedBalance('1574.5445', 'en-US')).toBe('1,574.54'); }); it(`test formatSavedBalance with 1 Million XEC balance input`, () => { expect(formatSavedBalance('1000000', 'en-US')).toBe('1,000,000'); }); it(`test formatSavedBalance with 1 Billion XEC balance input`, () => { expect(formatSavedBalance('1000000000', 'en-US')).toBe('1,000,000,000'); }); it(`test formatSavedBalance with total supply as XEC balance input`, () => { expect(formatSavedBalance('21000000000000', 'en-US')).toBe( '21,000,000,000,000', ); }); it(`test formatSavedBalance with > total supply as XEC balance input`, () => { expect(formatSavedBalance('31000000000000', 'en-US')).toBe( '31,000,000,000,000', ); }); it(`test formatSavedBalance with no balance`, () => { expect(formatSavedBalance('', 'en-US')).toBe('0'); }); it(`test formatSavedBalance with null input`, () => { expect(formatSavedBalance(null, 'en-US')).toBe('0'); }); it(`test formatSavedBalance with undefined sw.state.balance or sw.state.balance.totalBalance as input`, () => { expect(formatSavedBalance(undefined, 'en-US')).toBe('N/A'); }); it(`test formatSavedBalance with non-numeric input`, () => { expect(formatSavedBalance('CainBCHA', 'en-US')).toBe('NaN'); }); it(`test formatFiatBalance with zero XEC balance input`, () => { expect(formatFiatBalance(Number('0'), 'en-US')).toBe('0.00'); }); it(`test formatFiatBalance with a small XEC balance input with 2+ decimal figures`, () => { expect(formatFiatBalance(Number('565.54111'), 'en-US')).toBe('565.54'); }); it(`test formatFiatBalance with a large XEC balance input with 2+ decimal figures`, () => { expect(formatFiatBalance(Number('131646565.54111'), 'en-US')).toBe( '131,646,565.54', ); }); it(`test formatFiatBalance with no balance`, () => { expect(formatFiatBalance('', 'en-US')).toBe(''); }); it(`test formatFiatBalance with null input`, () => { expect(formatFiatBalance(null, 'en-US')).toBe(null); }); it(`test formatFiatBalance with undefined input`, () => { expect(formatFiatBalance(undefined, 'en-US')).toBe(undefined); }); + it(`test isValidSendToMany with null inputs`, () => { + expect(isValidSendToMany(null)).toBe('invalid address input'); + }); + it(`test isValidSendToMany with null addressInfo and valid valueString and ticker`, () => { + expect(isValidSendToMany(null, '233.32', 'XEC')).toBe( + 'invalid address input', + ); + }); + it(`test isValidSendToMany with valid addressInfo and ticker, null valueString`, () => { + const validAddressInfo = { + address: 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8', + isValid: true, + queryString: null, + amount: null, + }; + expect(isValidSendToMany(validAddressInfo, null, 'XEC')).toBe( + 'invalid value input', + ); + }); + it(`test isValidSendToMany with valid addressInfo and valueString, null ticker`, () => { + const validAddressInfo = { + address: 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8', + isValid: true, + queryString: null, + amount: null, + }; + expect(isValidSendToMany(validAddressInfo, '234.32', null)).toBe( + 'invalid ticker input', + ); + }); + it(`test isValidSendToMany with undefined addressInfo and valid valueString and ticker`, () => { + expect(isValidSendToMany(undefined, '233.32', 'XEC')).toBe( + 'invalid address input', + ); + }); + it(`test isValidSendToMany with valid addressInfo and ticker, undefined valueString`, () => { + const validAddressInfo = { + address: 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8', + isValid: true, + queryString: null, + amount: null, + }; + expect(isValidSendToMany(validAddressInfo, undefined, 'XEC')).toBe( + 'invalid value input', + ); + }); + it(`test isValidSendToMany with valid addressInfo and valueString, undefined ticker`, () => { + const validAddressInfo = { + address: 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8', + isValid: true, + queryString: null, + amount: null, + }; + expect(isValidSendToMany(validAddressInfo, '234.32', undefined)).toBe( + 'invalid ticker input', + ); + }); + it(`test isValidSendToMany with invalid addressInfo and valid valueString and ticker`, () => { + const validAddressInfo = { + address: 'ecash:qpatqlfooobarrrrrrrrrrkpyrlwu8', + isValid: false, + queryString: null, + amount: null, + }; + expect(isValidSendToMany(validAddressInfo, '234.32', 'XEC')).toBe( + 'Invalid XEC address', + ); + }); + it(`test isValidSendToMany with valid addressInfo and ticker, less than minimal valueString`, () => { + const validAddressInfo = { + address: 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8', + isValid: true, + queryString: null, + amount: null, + }; + expect(isValidSendToMany(validAddressInfo, '2.548', 'XEC')).toBe( + 'Send amount must be at least 5.5 XEC', + ); + }); }); diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js index 3fa8bdd32..9907eb4df 100644 --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -1,159 +1,191 @@ import BigNumber from 'bignumber.js'; -import { currency } from '@components/Common/Ticker.js'; +import { currency, isValidTokenPrefix } 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 !== 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; } }; export const formatSavedBalance = (swBalance, optionalLocale) => { try { if (swBalance === undefined) { return 'N/A'; } else { if (optionalLocale === undefined) { return new Number(swBalance).toLocaleString({ maximumFractionDigits: currency.cashDecimals, }); } else { return new Number(swBalance).toLocaleString(optionalLocale, { maximumFractionDigits: currency.cashDecimals, }); } } } catch (err) { return 'N/A'; } }; export const formatFiatBalance = (fiatBalance, optionalLocale) => { try { if (fiatBalance === 0) { return Number(fiatBalance).toFixed(currency.cashDecimals); } if (optionalLocale === undefined) { return fiatBalance.toLocaleString({ maximumFractionDigits: currency.cashDecimals, }); } return fiatBalance.toLocaleString(optionalLocale, { maximumFractionDigits: currency.cashDecimals, }); } catch (err) { return fiatBalance; } }; + +export const isValidSendToMany = (addressInfo, valueString, ticker) => { + let isValidInput = true; + + try { + if (addressInfo === null || addressInfo === undefined) { + return 'invalid address input'; + } else if (valueString === null || valueString === undefined) { + return 'invalid value input'; + } else if (ticker === null || ticker === undefined) { + return 'invalid ticker input'; + } + + const { address, isValid, queryString, amount } = addressInfo; + + // Is this valid address? + if (!isValid) { + isValidInput = `Invalid ${ticker} address`; + // If valid address but token format + if (isValidTokenPrefix(address)) { + isValidInput = `Token addresses are not supported for ${ticker} sends`; + } + // Is this send value above minimum + } else if (valueString < 5.5) { + // value can only be XEC ticker in multi recipient mode + isValidInput = `Send amount must be at least 5.5 XEC`; + } + return isValidInput; + } catch (err) { + return err; + } +};
Are you sure you want to send {formData.value}{' '} {currency.ticker} to {formData.address}?