6.001
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,357 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import BigNumber from 'bignumber.js';
+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 { StyledLink } from '@components/Common/Atoms';
+import * as etokenList from 'etoken-list';
+
+// 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 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);
+ }, []);
+
+ const [tokenId, setTokenId] = useState('');
+ const [tokenIdIsValid, setTokenIdIsValid] = useState(null);
+ const [totalAirdrop, setTotalAirdrop] = useState('');
+ 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 { value } = e.target;
+ setTokenIdIsValid(isValidTokenId(value));
+ setTokenId(value);
+ };
+
+ const handleTotalAirdropInput = e => {
+ const { value } = e.target;
+ setTotalAirdropIsValid(isValidXecAirdrop(value));
+ setTotalAirdrop(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(
+ tokenId,
+ latestBlock,
+ true,
+ );
+ } catch (err) {
+ console.log('Tools.calculateXecAirdrop() error: ' + err);
+ throw err;
+ }
+
+ 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(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: ' + tokenId,
+ 'Airdrop Calculation Error',
+ );
+ }
+
+ // 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 ? (
+ ''
+ ) : (
+ <>
+
+ {fiatPrice !== null && !isNaN(balances.totalBalance) && (
+
+ )}
+ >
+ )}
+
+
+
+ This normally takes a few seconds...
+
+
+
+
+
+
+
+
+
+
+
+
+ handleTokenIdInput(e)
+ }
+ />
+
+
+
+ handleTotalAirdropInput(e)
+ }
+ />
+
+
+
+ calculateXecAirdrop()
+ }
+ disabled={!airdropCalcInputIsValid}
+ >
+ Calculate Airdrop
+
+
+ {showAirdropOutputs && (
+ <>
+ {!airdropOutputIsValid &&
+ etokenHolders > 0 && (
+ <>
+
+
+ >
+ )}
+
+ One to Many Airdrop Payment
+ Outputs
+
+
+
+
+ Copy to Send screen
+
+
+ {
+ navigator.clipboard.writeText(
+ airdropRecipients,
+ );
+ generalNotification(
+ 'Copied to clipboard',
+ );
+ }}
+ >
+ 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,113 @@
+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 '../../Wallet/__mocks__/walletAndBalancesMock';
+import { BrowserRouter as Router } from 'react-router-dom';
+
+let realUseContext;
+let useContextMock;
+
+beforeEach(() => {
+ realUseContext = React.useContext;
+ useContextMock = React.useContext = jest.fn();
+
+ 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 XEC balance', () => {
+ useContextMock.mockReturnValue(walletWithoutBalancesMock);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with XEC balances', () => {
+ useContextMock.mockReturnValue(walletWithBalancesMock);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with XEC balances and tokens', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokens);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with XEC 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,315 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+
+exports[`Wallet with XEC balances 1`] = `
+Array [
+ "",
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
,
+]
+`;
+
+exports[`Wallet with XEC balances and tokens 1`] = `
+Array [
+ "",
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
,
+]
+`;
+
+exports[`Wallet with XEC balances and tokens and state field 1`] = `
+Array [
+
+ 0.06
+
+ XEC
+
,
+
+ $
+ NaN
+
+ USD
+
,
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
,
+]
+`;
+
+exports[`Wallet without XEC balance 1`] = `
+Array [
+ "",
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
,
+]
+`;
+
+exports[`Without wallet defined 1`] = `
+Array [
+ "",
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
,
+]
+`;
diff --git a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
--- a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
+++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
@@ -119,16 +119,16 @@