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,366 @@
+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,
+ isValidXecDividend,
+ isValidDividendOutputsArray,
+} 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 [isDivCalcModalVisible, setIsDivCalcModalVisible] = useState(false);
+ const [divCalcModalProgress, setDivCalcModalProgress] = 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 [totalDividend, setTotalDividend] = useState('');
+ const [totalDividendIsValid, setTotalDividendIsValid] = useState(null);
+ const [dividendRecipients, setDividendRecipients] = useState('');
+ const [dividendOutputIsValid, setDividendOutputIsValid] = useState(true);
+ const [etokenHolders, setEtokenHolders] = useState(new BigNumber(0));
+ const [showDividendOutputs, setShowDividendOutputs] = useState(false);
+
+ const { getBCH } = useBCH();
+
+ const handleTokenIdInput = e => {
+ const { value } = e.target;
+ setTokenIdIsValid(isValidTokenId(value));
+ setTokenId(value);
+ };
+
+ const handleTotalDividendInput = e => {
+ const { value } = e.target;
+ setTotalDividendIsValid(isValidXecDividend(value));
+ setTotalDividend(value);
+ };
+
+ const calculateXecDividend = async () => {
+ // display dividend calculation message modal
+ setIsDivCalcModalVisible(true);
+ setShowDividendOutputs(false); // hide any previous dividend outputs
+ passLoadingStatus(true);
+ setDivCalcModalProgress(25); // updated progress bar to 25%
+
+ const latestBlock = await getLatestBlockHeight(bchObj);
+
+ setDivCalcModalProgress(50);
+
+ etokenList.Config.SetUrl(currency.tokenDbUrl);
+
+ let dividendList;
+ try {
+ dividendList = await etokenList.List.GetAddressListFor(
+ tokenId,
+ latestBlock,
+ true,
+ );
+ } catch (err) {
+ console.log('Tools.calculateXecDividend() error: ' + err);
+ throw err;
+ }
+
+ setDivCalcModalProgress(75);
+
+ let totalTokenAmongstRecipients = new BigNumber(0);
+ let totalHolders = new BigNumber(dividendList.size); // amount of addresses that hold this eToken
+ setEtokenHolders(totalHolders);
+
+ // keep a cumulative total of each eToken holding in each address in dividendList
+ dividendList.forEach(
+ index =>
+ (totalTokenAmongstRecipients = totalTokenAmongstRecipients.plus(
+ new BigNumber(index),
+ )),
+ );
+
+ let circToDivRatio = new BigNumber(totalDividend).div(
+ totalTokenAmongstRecipients,
+ );
+
+ let resultString = '';
+
+ dividendList.forEach(
+ (element, index) =>
+ (resultString +=
+ convertEtokenToEcashAddr(index) +
+ ',' +
+ new BigNumber(element).multipliedBy(circToDivRatio) +
+ '\n'),
+ );
+
+ resultString = resultString.substring(0, resultString.length - 1); // remove the final newline
+ setDividendRecipients(resultString);
+
+ setDivCalcModalProgress(100);
+
+ if (!resultString) {
+ errorNotification(
+ null,
+ 'No holders found for eToken ID: ' + tokenId,
+ 'Dividend Calculation Error',
+ );
+ }
+
+ // validate the dividend values for each recipient
+ // Note: addresses are not validated as they are retrieved directly from onchain
+ setDividendOutputIsValid(isValidDividendOutputsArray(resultString));
+ setShowDividendOutputs(true); // display the dividend outputs TextArea
+ setIsDivCalcModalVisible(false);
+ passLoadingStatus(false);
+ };
+
+ const handleDivCalcModalCancel = () => {
+ // disable both the modal and the loading status so the user can re calculate without waiting for the current calculation to finish
+ setIsDivCalcModalVisible(false);
+ passLoadingStatus(false);
+ };
+
+ const handleDivCalcModalOk = () => {
+ // disable the modal only but let the loading status remain until end of dividend calculation execution
+ setIsDivCalcModalVisible(false);
+ };
+
+ let dividendCalcInputIsValid = tokenIdIsValid && totalDividendIsValid;
+
+ return (
+ <>
+ {!balances.totalBalance ? (
+ ''
+ ) : (
+ <>
+
+ {fiatPrice !== null && !isNaN(balances.totalBalance) && (
+
+ )}
+ >
+ )}
+
+
+
+ Please wait... (this takes approx. 10 seconds)
+
+
+
+
+
+
+
+
+
+
+
+
+ handleTokenIdInput(e)
+ }
+ />
+
+
+
+ handleTotalDividendInput(e)
+ }
+ />
+
+
+
+ calculateXecDividend()
+ }
+ disabled={!dividendCalcInputIsValid}
+ >
+ Calculate Pro-Rata Dividend
+
+ {!dividendOutputIsValid &&
+ etokenHolders > 0 &&
+ showDividendOutputs ? (
+ <>
+
+ >
+ ) : (
+ ''
+ )}
+
+ {showDividendOutputs ? (
+ <>
+
+ One to Many Dividend Payment
+ Outputs
+
+
+
+
+ Copy to Send screen
+
+
+ {
+ navigator.clipboard.writeText(
+ dividendRecipients,
+ );
+ 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/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 @@
,
XEC
eToken
diff --git a/web/cashtab/src/utils/__mocks__/mockXecDividendRecipients.js b/web/cashtab/src/utils/__mocks__/mockXecDividendRecipients.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab/src/utils/__mocks__/mockXecDividendRecipients.js
@@ -0,0 +1,13 @@
+export const validXecDividendList =
+ 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7\n' +
+ 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,67\n' +
+ 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376\n' +
+ 'ecash:qz2taa43tljkvnvkeqv9pyx337hmg0zclqfqjrqst4,673728\n' +
+ 'ecash:qp0hlj26nwjpk9c3f0umjz7qmwpzfh0fhckq4zj9s6,23673\n';
+
+export const invalidXecDividendList =
+ 'ecash:qrqgwxnaxlfagezvr2zj4s9yee6rrs96dyguh7zsvk,7\n' +
+ 'ecash:qzsha6zk9m0f3hlfe5q007zdwnzvn3vwuuzel2lfzv,3\n' +
+ 'ecash:qqlkyzmeupf7q8t2ttf2u8xgyk286pg4wyz0v403dj,4376\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,
} from '@utils/cashMethods';
@@ -345,4 +346,60 @@
const result = parseOpReturn(eTokenInputHex);
expect(result).toStrictEqual(mockParsedETokenOutputArray);
});
+
+ 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
@@ -12,6 +12,9 @@
isValidEtokenAddress,
isValidXecSendAmount,
isValidSendToMany,
+ isValidTokenId,
+ isValidXecDividend,
+ isValidDividendOutputsArray,
} from '../validation';
import { currency } from '@components/Common/Ticker.js';
import { fromSmallestDenomination } from '@utils/cashMethods';
@@ -21,6 +24,10 @@
noCovidStatsInvalid,
cGenStatsValid,
} from '../__mocks__/mockTokenStats';
+import {
+ validXecDividendList,
+ invalidXecDividendList,
+} from '../__mocks__/mockXecDividendRecipients';
describe('Validation utils', () => {
it(`Returns 'false' if ${currency.ticker} send amount is a valid send amount`, () => {
@@ -350,4 +357,71 @@
const testXecSendAmount = undefined;
expect(isValidXecSendAmount(testXecSendAmount)).toBe(false);
});
+ it(`isValidTokenId accepts valid token ID`, () => {
+ const testValidTokenId =
+ '1c6c9c64d70b285befe733f175d0f384538576876bd280b10587df81279d3f5e';
+ expect(isValidTokenId(testValidTokenId)).toBe(true);
+ });
+ 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(`isValidXecDividend accepts valid Total Dividend Amount`, () => {
+ const testDividendTotal = '1000000';
+ expect(isValidXecDividend(testDividendTotal)).toBe(true);
+ });
+ it(`isValidXecDividend rejects null`, () => {
+ const testDividendTotal = null;
+ expect(isValidXecDividend(testDividendTotal)).toBe(false);
+ });
+ it(`isValidXecDividend rejects undefined`, () => {
+ const testDividendTotal = undefined;
+ expect(isValidXecDividend(testDividendTotal)).toBe(false);
+ });
+ it(`isValidXecDividend rejects empty string`, () => {
+ const testDividendTotal = '';
+ expect(isValidXecDividend(testDividendTotal)).toBe(false);
+ });
+ it(`isValidTokenId rejects an alphanumeric input`, () => {
+ const testDividendTotal = 'a73hsyujs3737';
+ expect(isValidXecDividend(testDividendTotal)).toBe(false);
+ });
+ it(`isValidTokenId rejects a number !> 0 in string format`, () => {
+ const testDividendTotal = '0';
+ expect(isValidXecDividend(testDividendTotal)).toBe(false);
+ });
+ it(`isValidDividendOutputsArray accepts a dividend list with valid XEC values`, () => {
+ expect(isValidDividendOutputsArray(validXecDividendList)).toBe(true);
+ });
+ it(`isValidDividendOutputsArray rejects a dividend list with invalid XEC values`, () => {
+ expect(isValidDividendOutputsArray(invalidXecDividendList)).toBe(false);
+ });
+ it(`isValidDividendOutputsArray rejects null`, () => {
+ const testDividendListValues = null;
+ expect(isValidDividendOutputsArray(testDividendListValues)).toBe(false);
+ });
+ it(`isValidDividendOutputsArray rejects undefined`, () => {
+ const testDividendListValues = undefined;
+ expect(isValidDividendOutputsArray(testDividendListValues)).toBe(false);
+ });
+ it(`isValidDividendOutputsArray rejects empty string`, () => {
+ const testDividendListValues = '';
+ expect(isValidDividendOutputsArray(testDividendListValues)).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
@@ -229,6 +229,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.
@@ -487,3 +521,13 @@
return false;
};
+
+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
@@ -175,7 +175,6 @@
return isValidXecAddress;
};
-
export const isValidEtokenAddress = addr => {
/*
Returns true for a valid eToken address
@@ -226,3 +225,50 @@
parseFloat(xecSendAmount) >= fromSmallestDenomination(currency.dustSats)
);
};
+
+// XEC dividend field validations
+export const isValidTokenId = tokenId => {
+ const format = /[ `!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/;
+ const specialCharCheck = format.test(tokenId);
+
+ return (
+ typeof tokenId === 'string' &&
+ tokenId.length > 0 &&
+ tokenId.trim() != '' &&
+ !specialCharCheck
+ );
+};
+
+export const isValidXecDividend = xecDividend => {
+ return (
+ typeof xecDividend === 'string' &&
+ xecDividend.length > 0 &&
+ xecDividend.trim() != '' &&
+ new BigNumber(xecDividend).gt(0)
+ );
+};
+
+export const isValidDividendOutputsArray = dividendOutputsArray => {
+ if (!dividendOutputsArray) {
+ return false;
+ }
+
+ const addressStringArray = dividendOutputsArray.split('\n');
+ const arrayLength = addressStringArray.length;
+ let isValid = true;
+
+ for (let i = 0; i < arrayLength; i++) {
+ let valueString = addressStringArray[i].split(',')[1];
+
+ // if the XEC being sent is less than dust sats
+ if (
+ new BigNumber(valueString).lt(
+ fromSmallestDenomination(currency.dustSats),
+ )
+ ) {
+ isValid = false;
+ }
+ }
+
+ return isValid;
+};