eToken
diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js
--- a/web/cashtab/src/components/Send/Send.js
+++ b/web/cashtab/src/components/Send/Send.js
@@ -177,6 +177,22 @@
});
}
+ // if this was routed from the Tool screen's Airdrop Calculator then
+ // switch to multiple recipient mode and prepopulate the recipients field
+ if (location && location.state && location.state.airdropRecipients) {
+ setIsOneToManyXECSend(true);
+ setFormData({
+ address: location.state.airdropRecipients,
+ });
+
+ // validate the airdrop outputs from the calculator
+ handleMultiAddressChange({
+ target: {
+ value: location.state.airdropRecipients,
+ },
+ });
+ }
+
// Do not set txInfo in state if query strings are not present
if (
!window.location ||
diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js
--- a/web/cashtab/src/components/Send/SendToken.js
+++ b/web/cashtab/src/components/Send/SendToken.js
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { WalletContext } from '@utils/context';
import { Form, message, Row, Col, Alert, Descriptions } from 'antd';
@@ -40,6 +41,34 @@
const AntdDescriptionsWrapper = styled.div`
${AntdDescriptionsCss}
`;
+const AirdropButton = styled.div`
+ text-align: center;
+ width: 100%;
+ padding: 10px;
+ border-radius: 5px;
+ background: ${props => props.theme.sentMessage};
+ a {
+ color: ${props => props.theme.darkBlue};
+ margin: 0;
+ font-size: 11px;
+ border: 1px solid ${props => props.theme.darkBlue};
+ border-radius: 5px;
+ padding: 2px 10px;
+ opacity: 0.6;
+ }
+ a:hover {
+ opacity: 1;
+ border-color: ${props => props.theme.eCashBlue};
+ color: ${props => props.theme.contrast};
+ background: ${props => props.theme.eCashBlue};
+ }
+ ${({ received, ...props }) =>
+ received &&
+ `
+ text-align: left;
+ background: ${props.theme.receivedMessage};
+ `}
+`;
const SendToken = ({ tokenId, jestBCH, passLoadingStatus }) => {
const { wallet, apiError } = React.useContext(WalletContext);
@@ -386,6 +415,20 @@
{token.tokenId}
+
+
+
+ Airdrop XEC to holders
+
+
{tokenStats && (
<>
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
--- a/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap
+++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/Send.test.js.snap
@@ -6,7 +6,7 @@
className="sc-eNQAEJ bPMOWW"
>
MigrationTestAlpha
@@ -43,7 +43,7 @@
}
>
0
@@ -476,7 +476,7 @@
className="sc-eNQAEJ bPMOWW"
>
MigrationTestAlpha
@@ -513,7 +513,7 @@
}
>
0
@@ -946,7 +946,7 @@
className="sc-eNQAEJ bPMOWW"
>
MigrationTestAlpha
@@ -990,7 +990,7 @@
}
>
0
@@ -1424,7 +1424,7 @@
className="sc-eNQAEJ bPMOWW"
>
MigrationTestAlpha
@@ -1461,7 +1461,7 @@
}
>
0
@@ -1926,7 +1926,7 @@
}
>
0
diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap
--- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap
+++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/CreateTokenForm.test.js.snap
@@ -2,7 +2,7 @@
exports[`Wallet with BCH balances and tokens and state field 1`] = `
Create a Token
diff --git a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap
--- a/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap
+++ b/web/cashtab/src/components/Tokens/__tests__/__snapshots__/Tokens.test.js.snap
@@ -6,7 +6,7 @@
className="sc-kkGfuU hVVqAi"
>
MigrationTestAlpha
@@ -30,7 +30,7 @@
className="sc-hMqMXs kjfrok"
>
Create a Token
@@ -61,7 +61,7 @@
className="sc-kkGfuU hVVqAi"
>
MigrationTestAlpha
@@ -85,7 +85,7 @@
className="sc-hMqMXs kjfrok"
>
Create a Token
@@ -116,7 +116,7 @@
className="sc-kkGfuU hVVqAi"
>
MigrationTestAlpha
@@ -140,7 +140,7 @@
className="sc-hMqMXs kjfrok"
>
Create a Token
@@ -520,7 +520,7 @@
className="sc-kkGfuU hVVqAi"
>
MigrationTestAlpha
@@ -544,7 +544,7 @@
className="sc-hMqMXs kjfrok"
>
Create a Token
@@ -594,7 +594,7 @@
className="sc-hMqMXs kjfrok"
>
Create a Token
diff --git a/web/cashtab/src/components/Tools/Tools.js b/web/cashtab/src/components/Tools/Tools.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab/src/components/Tools/Tools.js
@@ -0,0 +1,456 @@
+import React, { useState, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import PropTypes from 'prop-types';
+import BigNumber from 'bignumber.js';
+import styled from 'styled-components';
+import { WalletContext } from '@utils/context';
+import { AntdFormWrapper } from '@components/Common/EnhancedInputs';
+import { AdvancedCollapse } from '@components/Common/StyledCollapse';
+import { Form, Alert, Collapse, Input, Modal, Spin, Progress } from 'antd';
+const { Panel } = Collapse;
+const { TextArea } = Input;
+import { Row, Col } from 'antd';
+import { SmartButton } from '@components/Common/PrimaryButton';
+import useBCH from '@hooks/useBCH';
+import {
+ errorNotification,
+ generalNotification,
+} from '@components/Common/Notifications';
+import { currency } from '@components/Common/Ticker.js';
+import BalanceHeader from '@components/Common/BalanceHeader';
+import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat';
+import {
+ getWalletState,
+ getLatestBlockHeight,
+ convertEtokenToEcashAddr,
+ fromSmallestDenomination,
+} from '@utils/cashMethods';
+import {
+ isValidTokenId,
+ isValidXecAirdrop,
+ isValidAirdropOutputsArray,
+} from '@utils/validation';
+import { CustomSpinner } from '@components/Common/CustomIcons';
+import * as etokenList from 'etoken-list';
+import {
+ ZeroBalanceHeader,
+ SidePaddingCtn,
+ WalletInfoCtn,
+} from '@components/Common/Atoms';
+import WalletLabel from '@components/Common/WalletLabel.js';
+import { Link } from 'react-router-dom';
+
+const AirdropActions = styled.div`
+ text-align: center;
+ width: 100%;
+ padding: 10px;
+ border-radius: 5px;
+ a {
+ color: ${props => props.theme.contrast};
+ margin: 0;
+ font-size: 11px;
+ border: 1px solid ${props => props.theme.contrast};
+ border-radius: 5px;
+ padding: 2px 10px;
+ opacity: 0.6;
+ }
+ a:hover {
+ opacity: 1;
+ border-color: ${props => props.theme.eCashBlue};
+ color: ${props => props.theme.contrast};
+ background: ${props => props.theme.eCashBlue};
+ }
+ ${({ received, ...props }) =>
+ received &&
+ `
+ text-align: left;
+ background: ${props.theme.receivedMessage};
+ `}
+`;
+
+// Note jestBCH is only used for unit tests; BCHJS must be mocked for jest
+const Tools = ({ jestBCH, passLoadingStatus }) => {
+ const ContextValue = React.useContext(WalletContext);
+ const { wallet, fiatPrice, cashtabSettings } = ContextValue;
+ const location = useLocation();
+ const walletState = getWalletState(wallet);
+ const { balances } = walletState;
+
+ const [bchObj, setBchObj] = useState(false);
+ const [isAirdropCalcModalVisible, setIsAirdropCalcModalVisible] =
+ useState(false);
+ const [airdropCalcModalProgress, setAirdropCalcModalProgress] = useState(0); // the dynamic % progress bar
+
+ useEffect(() => {
+ // jestBCH is only ever specified for unit tests, otherwise app will use getBCH();
+ const BCH = jestBCH ? jestBCH : getBCH();
+
+ // set the BCH instance to state, for other functions to reference
+ setBchObj(BCH);
+
+ if (location && location.state && location.state.airdropEtokenId) {
+ setFormData({
+ ...formData,
+ tokenId: location.state.airdropEtokenId,
+ });
+ handleTokenIdInput({
+ target: {
+ value: location.state.airdropEtokenId,
+ },
+ });
+ }
+ }, []);
+
+ const [formData, setFormData] = useState({
+ tokenId: '',
+ totalAirdrop: '',
+ });
+
+ const [tokenIdIsValid, setTokenIdIsValid] = useState(null);
+ const [totalAirdropIsValid, setTotalAirdropIsValid] = useState(null);
+ const [airdropRecipients, setAirdropRecipients] = useState('');
+ const [airdropOutputIsValid, setAirdropOutputIsValid] = useState(true);
+ const [etokenHolders, setEtokenHolders] = useState(new BigNumber(0));
+ const [showAirdropOutputs, setShowAirdropOutputs] = useState(false);
+
+ const { getBCH } = useBCH();
+
+ const handleTokenIdInput = e => {
+ const { name, value } = e.target;
+ setTokenIdIsValid(isValidTokenId(value));
+ setFormData(p => ({
+ ...p,
+ [name]: value,
+ }));
+ };
+
+ const handleTotalAirdropInput = e => {
+ const { name, value } = e.target;
+ setTotalAirdropIsValid(isValidXecAirdrop(value));
+ setFormData(p => ({
+ ...p,
+ [name]: value,
+ }));
+ };
+
+ const calculateXecAirdrop = async () => {
+ // display airdrop calculation message modal
+ setIsAirdropCalcModalVisible(true);
+ setShowAirdropOutputs(false); // hide any previous airdrop outputs
+ passLoadingStatus(true);
+ setAirdropCalcModalProgress(25); // updated progress bar to 25%
+
+ const latestBlock = await getLatestBlockHeight(bchObj);
+
+ setAirdropCalcModalProgress(50);
+
+ etokenList.Config.SetUrl(currency.tokenDbUrl);
+
+ let airdropList;
+ try {
+ airdropList = await etokenList.List.GetAddressListFor(
+ formData.tokenId,
+ latestBlock,
+ true,
+ );
+ } catch (err) {
+ console.log('Tools.calculateXecAirdrop() error: ' + err);
+ errorNotification(
+ err,
+ 'Error retrieving airdrop recipients',
+ 'etokenList.List.GetAddressListFor() error',
+ );
+ setIsAirdropCalcModalVisible(false);
+ passLoadingStatus(false);
+ return;
+ }
+
+ if (!airdropList) {
+ errorNotification(
+ null,
+ 'No recipients found for tokenId ' + formData.tokenId,
+ 'Airdrop Calculation Error',
+ );
+ setIsAirdropCalcModalVisible(false);
+ passLoadingStatus(false);
+ return;
+ }
+
+ setAirdropCalcModalProgress(75);
+
+ let totalTokenAmongstRecipients = new BigNumber(0);
+ let totalHolders = new BigNumber(airdropList.size); // amount of addresses that hold this eToken
+ setEtokenHolders(totalHolders);
+
+ // keep a cumulative total of each eToken holding in each address in airdropList
+ airdropList.forEach(
+ index =>
+ (totalTokenAmongstRecipients = totalTokenAmongstRecipients.plus(
+ new BigNumber(index),
+ )),
+ );
+
+ let circToAirdropRatio = new BigNumber(formData.totalAirdrop).div(
+ totalTokenAmongstRecipients,
+ );
+
+ let resultString = '';
+
+ airdropList.forEach(
+ (element, index) =>
+ (resultString +=
+ convertEtokenToEcashAddr(index) +
+ ',' +
+ new BigNumber(element).multipliedBy(circToAirdropRatio) +
+ '\n'),
+ );
+
+ resultString = resultString.substring(0, resultString.length - 1); // remove the final newline
+ setAirdropRecipients(resultString);
+
+ setAirdropCalcModalProgress(100);
+
+ if (!resultString) {
+ errorNotification(
+ null,
+ 'No holders found for eToken ID: ' + formData.tokenId,
+ 'Airdrop Calculation Error',
+ );
+ return;
+ }
+
+ // validate the airdrop values for each recipient
+ // Note: addresses are not validated as they are retrieved directly from onchain
+ setAirdropOutputIsValid(isValidAirdropOutputsArray(resultString));
+ setShowAirdropOutputs(true); // display the airdrop outputs TextArea
+ setIsAirdropCalcModalVisible(false);
+ passLoadingStatus(false);
+ };
+
+ const handleAirdropCalcModalCancel = () => {
+ setIsAirdropCalcModalVisible(false);
+ passLoadingStatus(false);
+ };
+
+ let airdropCalcInputIsValid = tokenIdIsValid && totalAirdropIsValid;
+
+ return (
+ <>
+
+
+ {!balances.totalBalance ? (
+
+ You currently have 0 {currency.ticker}
+
+ Deposit some funds to use this feature
+
+ ) : (
+ <>
+
+ {fiatPrice !== null && (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleTokenIdInput(e)
+ }
+ />
+
+
+
+ handleTotalAirdropInput(e)
+ }
+ />
+
+
+
+ calculateXecAirdrop()
+ }
+ disabled={
+ !airdropCalcInputIsValid ||
+ !tokenIdIsValid
+ }
+ >
+ Calculate Airdrop
+
+
+ {showAirdropOutputs && (
+ <>
+ {!airdropOutputIsValid &&
+ etokenHolders > 0 && (
+ <>
+
+
+ >
+ )}
+
+ One to Many Airdrop Payment
+ Outputs
+
+
+
+
+
+ Copy to Send screen
+
+
+ {
+ navigator.clipboard.writeText(
+ airdropRecipients,
+ );
+ generalNotification(
+ 'Airdrop recipients copied to clipboard',
+ 'Copied',
+ );
+ }}
+ >
+ Copy to Clipboard
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ >
+ );
+};
+
+Tools.defaultProps = {
+ passLoadingStatus: status => {
+ console.log(status);
+ },
+};
+
+Tools.propTypes = {
+ jestBCH: PropTypes.object,
+ passLoadingStatus: PropTypes.func,
+};
+
+export default Tools;
diff --git a/web/cashtab/src/components/Tools/__tests__/Tools.test.js b/web/cashtab/src/components/Tools/__tests__/Tools.test.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab/src/components/Tools/__tests__/Tools.test.js
@@ -0,0 +1,115 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { ThemeProvider } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+import Tools from '@components/Tools/Tools';
+import BCHJS from '@psf/bch-js';
+import {
+ walletWithBalancesAndTokens,
+ walletWithBalancesMock,
+ walletWithoutBalancesMock,
+ walletWithBalancesAndTokensWithCorrectState,
+} from '../../Home/__mocks__/walletAndBalancesMock';
+import { BrowserRouter as Router } from 'react-router-dom';
+
+let realUseContext;
+let useContextMock;
+
+beforeEach(() => {
+ realUseContext = React.useContext;
+ useContextMock = React.useContext = jest.fn();
+
+ // Mock method not implemented in JSDOM
+ // See reference at https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // Deprecated
+ removeListener: jest.fn(), // Deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+ });
+});
+
+afterEach(() => {
+ React.useContext = realUseContext;
+});
+
+test('Wallet without BCH balance', () => {
+ useContextMock.mockReturnValue(walletWithoutBalancesMock);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances', () => {
+ useContextMock.mockReturnValue(walletWithBalancesMock);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokens);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens and state field', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokensWithCorrectState);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Without wallet defined', () => {
+ useContextMock.mockReturnValue({
+ wallet: {},
+ balances: { totalBalance: 0 },
+ loading: false,
+ });
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/web/cashtab/src/components/Tools/__tests__/__snapshots__/Tools.test.js.snap b/web/cashtab/src/components/Tools/__tests__/__snapshots__/Tools.test.js.snap
new file mode 100644
--- /dev/null
+++ b/web/cashtab/src/components/Tools/__tests__/__snapshots__/Tools.test.js.snap
@@ -0,0 +1,408 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+
+exports[`Wallet with BCH balances 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ You currently have 0
+ XEC
+
+ Deposit some funds to use this feature
+
+
,
+
,
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Wallet with BCH balances and tokens 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ You currently have 0
+ XEC
+
+ Deposit some funds to use this feature
+
+
,
+
,
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Wallet with BCH balances and tokens and state field 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ 0.06
+
+ XEC
+
+
+ $
+ NaN
+
+ USD
+
+
,
+
,
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Wallet without BCH balance 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ You currently have 0
+ XEC
+
+ Deposit some funds to use this feature
+
+
,
+
,
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Without wallet defined 1`] = `
+Array [
+
+
+ You currently have 0
+ XEC
+
+ Deposit some funds to use this feature
+
+
,
+
,
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
diff --git a/web/cashtab/src/utils/__mocks__/mockXecAirdropRecipients.js b/web/cashtab/src/utils/__mocks__/mockXecAirdropRecipients.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab/src/utils/__mocks__/mockXecAirdropRecipients.js
@@ -0,0 +1,27 @@
+export const validXecAirdropList =
+ 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7\n' +
+ 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67\n' +
+ 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376\n' +
+ 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728\n' +
+ 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673\n';
+
+export const invalidXecAirdropList =
+ 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7\n' +
+ 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3\n' +
+ 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376\n' +
+ 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728\n' +
+ 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673\n';
+
+export const invalidXecAirdropListMultipleInvalidValues =
+ 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7\n' +
+ 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3,3,4\n' +
+ 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4,1,2\n' +
+ 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728\n' +
+ 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673\n';
+
+export const invalidXecAirdropListMultipleValidValues =
+ 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7\n' +
+ 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,8,9,44\n' +
+ 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376,43,1212\n' +
+ 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728\n' +
+ 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673\n';
diff --git a/web/cashtab/src/utils/__tests__/cashMethods.test.js b/web/cashtab/src/utils/__tests__/cashMethods.test.js
--- a/web/cashtab/src/utils/__tests__/cashMethods.test.js
+++ b/web/cashtab/src/utils/__tests__/cashMethods.test.js
@@ -13,6 +13,7 @@
toLegacyCash,
toLegacyToken,
toLegacyCashArray,
+ convertEtokenToEcashAddr,
parseOpReturn,
isExcludedUtxo,
whichUtxosWereAdded,
@@ -714,4 +715,59 @@
),
).toBe(false);
});
+ test('convertEtokenToEcashAddr successfully converts a valid eToken address to eCash', async () => {
+ const result = convertEtokenToEcashAddr(
+ 'etoken:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gcldpffcs',
+ );
+ expect(result).toStrictEqual(
+ 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8',
+ );
+ });
+
+ test('convertEtokenToEcashAddr successfully converts prefixless eToken address as input', async () => {
+ const result = convertEtokenToEcashAddr(
+ 'qpatql05s9jfavnu0tv6lkjjk25n6tmj9gcldpffcs',
+ );
+ expect(result).toStrictEqual(
+ 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8',
+ );
+ });
+
+ test('convertEtokenToEcashAddr throws error with an invalid eToken address as input', async () => {
+ const result = convertEtokenToEcashAddr('etoken:qpj9gcldpffcs');
+ expect(result).toStrictEqual(
+ new Error(
+ 'cashMethods.convertToEcashAddr() error: etoken:qpj9gcldpffcs is not a valid etoken address',
+ ),
+ );
+ });
+
+ test('convertEtokenToEcashAddr throws error with an ecash address as input', async () => {
+ const result = convertEtokenToEcashAddr(
+ 'ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8',
+ );
+ expect(result).toStrictEqual(
+ new Error(
+ 'cashMethods.convertToEcashAddr() error: ecash:qpatql05s9jfavnu0tv6lkjjk25n6tmj9gkpyrlwu8 is not a valid etoken address',
+ ),
+ );
+ });
+
+ test('convertEtokenToEcashAddr throws error with null input', async () => {
+ const result = convertEtokenToEcashAddr(null);
+ expect(result).toStrictEqual(
+ new Error(
+ 'cashMethods.convertToEcashAddr() error: No etoken address provided',
+ ),
+ );
+ });
+
+ test('convertEtokenToEcashAddr throws error with empty string input', async () => {
+ const result = convertEtokenToEcashAddr('');
+ expect(result).toStrictEqual(
+ new Error(
+ 'cashMethods.convertToEcashAddr() error: No etoken address provided',
+ ),
+ );
+ });
});
diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js
--- a/web/cashtab/src/utils/__tests__/validation.test.js
+++ b/web/cashtab/src/utils/__tests__/validation.test.js
@@ -14,6 +14,9 @@
isValidSendToMany,
isValidUtxo,
isValidBchApiUtxoObject,
+ isValidTokenId,
+ isValidXecAirdrop,
+ isValidAirdropOutputsArray,
} from '../validation';
import { currency } from '@components/Common/Ticker.js';
import { fromSmallestDenomination } from '@utils/cashMethods';
@@ -23,6 +26,12 @@
noCovidStatsInvalid,
cGenStatsValid,
} from '../__mocks__/mockTokenStats';
+import {
+ validXecAirdropList,
+ invalidXecAirdropList,
+ invalidXecAirdropListMultipleInvalidValues,
+ invalidXecAirdropListMultipleValidValues,
+} from '../__mocks__/mockXecAirdropRecipients';
import {
validUtxo,
@@ -423,4 +432,114 @@
it(`isValidBchApiUtxoObject returns true for valid bch-api utxos object`, () => {
expect(isValidBchApiUtxoObject(utxosAfterSentTxIncremental)).toBe(true);
});
+ it(`isValidTokenId accepts valid token ID that is 64 chars in length`, () => {
+ const testValidTokenId =
+ '1c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e';
+ expect(isValidTokenId(testValidTokenId)).toBe(true);
+ });
+ it(`isValidTokenId rejects a token ID that is less than 64 chars in length`, () => {
+ const testValidTokenId = '111111thisisaninvalidtokenid';
+ expect(isValidTokenId(testValidTokenId)).toBe(false);
+ });
+ it(`isValidTokenId rejects a token ID that is more than 64 chars in length`, () => {
+ const testValidTokenId =
+ '111111111c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e';
+ expect(isValidTokenId(testValidTokenId)).toBe(false);
+ });
+ it(`isValidTokenId rejects a token ID number that is 64 digits in length`, () => {
+ const testValidTokenId = 8912345678912345678912345678912345678912345678912345678912345679;
+ expect(isValidTokenId(testValidTokenId)).toBe(false);
+ });
+ it(`isValidTokenId rejects null`, () => {
+ const testValidTokenId = null;
+ expect(isValidTokenId(testValidTokenId)).toBe(false);
+ });
+ it(`isValidTokenId rejects undefined`, () => {
+ const testValidTokenId = undefined;
+ expect(isValidTokenId(testValidTokenId)).toBe(false);
+ });
+ it(`isValidTokenId rejects empty string`, () => {
+ const testValidTokenId = '';
+ expect(isValidTokenId(testValidTokenId)).toBe(false);
+ });
+ it(`isValidTokenId rejects special character input`, () => {
+ const testValidTokenId = '^&$%@&^$@&%$!';
+ expect(isValidTokenId(testValidTokenId)).toBe(false);
+ });
+ it(`isValidTokenId rejects non-alphanumeric input`, () => {
+ const testValidTokenId = 99999999999;
+ expect(isValidTokenId(testValidTokenId)).toBe(false);
+ });
+ it(`isValidXecAirdrop accepts valid Total Airdrop Amount`, () => {
+ const testAirdropTotal = '1000000';
+ expect(isValidXecAirdrop(testAirdropTotal)).toBe(true);
+ });
+ it(`isValidXecAirdrop rejects null`, () => {
+ const testAirdropTotal = null;
+ expect(isValidXecAirdrop(testAirdropTotal)).toBe(false);
+ });
+ it(`isValidXecAirdrop rejects undefined`, () => {
+ const testAirdropTotal = undefined;
+ expect(isValidXecAirdrop(testAirdropTotal)).toBe(false);
+ });
+ it(`isValidXecAirdrop rejects empty string`, () => {
+ const testAirdropTotal = '';
+ expect(isValidXecAirdrop(testAirdropTotal)).toBe(false);
+ });
+ it(`isValidTokenId rejects an alphanumeric input`, () => {
+ const testAirdropTotal = 'a73hsyujs3737';
+ expect(isValidXecAirdrop(testAirdropTotal)).toBe(false);
+ });
+ it(`isValidTokenId rejects a number !> 0 in string format`, () => {
+ const testAirdropTotal = '0';
+ expect(isValidXecAirdrop(testAirdropTotal)).toBe(false);
+ });
+ it(`isValidAirdropOutputsArray accepts an airdrop list with valid XEC values`, () => {
+ // Tools.js logic removes the EOF newline before validation
+ const outputArray = validXecAirdropList.substring(
+ 0,
+ validXecAirdropList.length - 1,
+ );
+ expect(isValidAirdropOutputsArray(outputArray)).toBe(true);
+ });
+ it(`isValidAirdropOutputsArray rejects an airdrop list with invalid XEC values`, () => {
+ // Tools.js logic removes the EOF newline before validation
+ const outputArray = invalidXecAirdropList.substring(
+ 0,
+ invalidXecAirdropList.length - 1,
+ );
+ expect(isValidAirdropOutputsArray(outputArray)).toBe(false);
+ });
+ it(`isValidAirdropOutputsArray rejects null`, () => {
+ const testAirdropListValues = null;
+ expect(isValidAirdropOutputsArray(testAirdropListValues)).toBe(false);
+ });
+ it(`isValidAirdropOutputsArray rejects undefined`, () => {
+ const testAirdropListValues = undefined;
+ expect(isValidAirdropOutputsArray(testAirdropListValues)).toBe(false);
+ });
+ it(`isValidAirdropOutputsArray rejects empty string`, () => {
+ const testAirdropListValues = '';
+ expect(isValidAirdropOutputsArray(testAirdropListValues)).toBe(false);
+ });
+ it(`isValidAirdropOutputsArray rejects an airdrop list with multiple invalid XEC values per row`, () => {
+ // Tools.js logic removes the EOF newline before validation
+ const addressStringArray =
+ invalidXecAirdropListMultipleInvalidValues.substring(
+ 0,
+ invalidXecAirdropListMultipleInvalidValues.length - 1,
+ );
+
+ expect(isValidAirdropOutputsArray(addressStringArray)).toBe(false);
+ });
+ it(`isValidAirdropOutputsArray rejects an airdrop list with multiple valid XEC values per row`, () => {
+ // Tools.js logic removes the EOF newline before validation
+ const addressStringArray =
+ invalidXecAirdropListMultipleValidValues.substring(
+ 0,
+ invalidXecAirdropListMultipleValidValues.length - 1,
+ );
+
+ expect(isValidAirdropOutputsArray(addressStringArray)).toBe(false);
+ });
});
diff --git a/web/cashtab/src/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js
--- a/web/cashtab/src/utils/cashMethods.js
+++ b/web/cashtab/src/utils/cashMethods.js
@@ -234,6 +234,40 @@
return wallet.state;
};
+export function convertEtokenToEcashAddr(eTokenAddress) {
+ if (!eTokenAddress) {
+ return new Error(
+ `cashMethods.convertToEcashAddr() error: No etoken address provided`,
+ );
+ }
+
+ // Confirm input is a valid eToken address
+ const isValidInput = isValidEtokenAddress(eTokenAddress);
+ if (!isValidInput) {
+ return new Error(
+ `cashMethods.convertToEcashAddr() error: ${eTokenAddress} is not a valid etoken address`,
+ );
+ }
+
+ // Check for etoken: prefix
+ const isPrefixedEtokenAddress = eTokenAddress.slice(0, 7) === 'etoken:';
+
+ // If no prefix, assume it is checksummed for an etoken: prefix
+ const testedEtokenAddr = isPrefixedEtokenAddress
+ ? eTokenAddress
+ : `etoken:${eTokenAddress}`;
+
+ let ecashAddress;
+ try {
+ const { type, hash } = cashaddr.decode(testedEtokenAddr);
+ ecashAddress = cashaddr.encode('ecash', type, hash);
+ } catch (err) {
+ return err;
+ }
+
+ return ecashAddress;
+}
+
export function convertToEcashPrefix(bitcoincashPrefixedAddress) {
// 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.
@@ -894,3 +928,13 @@
incrementallyHydratedUtxosIncludesAllUtxosInLatestUtxoApiFetch = true;
return incrementallyHydratedUtxosIncludesAllUtxosInLatestUtxoApiFetch;
};
+
+export const getLatestBlockHeight = async BCH => {
+ try {
+ const latestBlock = await BCH.Blockchain.getBlockCount();
+ return latestBlock;
+ } catch (err) {
+ console.log('cashMethods.getLatestBlockHeight() error: ' + err);
+ throw err;
+ }
+};
diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js
--- a/web/cashtab/src/utils/validation.js
+++ b/web/cashtab/src/utils/validation.js
@@ -298,3 +298,52 @@
return isValidBchApiUtxoObject;
};
+
+// XEC airdrop field validations
+export const isValidTokenId = tokenId => {
+ const format = /[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/;
+ const specialCharCheck = format.test(tokenId);
+
+ return (
+ typeof tokenId === 'string' &&
+ tokenId.length === 64 &&
+ tokenId.trim() != '' &&
+ !specialCharCheck
+ );
+};
+
+export const isValidXecAirdrop = xecAirdrop => {
+ return (
+ typeof xecAirdrop === 'string' &&
+ xecAirdrop.length > 0 &&
+ xecAirdrop.trim() != '' &&
+ new BigNumber(xecAirdrop).gt(0)
+ );
+};
+
+export const isValidAirdropOutputsArray = airdropOutputsArray => {
+ if (!airdropOutputsArray) {
+ return false;
+ }
+
+ let isValid = true;
+
+ // split by individual rows
+ const addressStringArray = airdropOutputsArray.split('\n');
+
+ for (let i = 0; i < addressStringArray.length; i++) {
+ const substring = addressStringArray[i].split(',');
+ let valueString = substring[1];
+ // if the XEC being sent is less than dust sats or contains extra values per line
+ if (
+ new BigNumber(valueString).lt(
+ fromSmallestDenomination(currency.dustSats),
+ ) ||
+ substring.length !== 2
+ ) {
+ isValid = false;
+ }
+ }
+
+ return isValid;
+};