diff --git a/web/cashtab/extension/src/components/App.js b/web/cashtab/extension/src/components/App.js --- a/web/cashtab/extension/src/components/App.js +++ b/web/cashtab/extension/src/components/App.js @@ -10,11 +10,13 @@ CaretRightOutlined, SettingFilled, AppstoreAddOutlined, + UngroupOutlined, } from '@ant-design/icons'; import Wallet from '@components/Wallet/Wallet'; import Tokens from '@components/Tokens/Tokens'; import Send from '@components/Send/Send'; import SendToken from '@components/Send/SendToken'; +import Tools from '@components/Tools/Tools'; import Configure from '@components/Configure/Configure'; import NotFound from '@components/NotFound'; import CashTab from '@assets/cashtab_xec.png'; @@ -101,10 +103,10 @@ outline: none; } cursor: pointer; - padding: 24px 12px 12px 12px; - margin: 0 28px; + padding: 24px 6px 12px 12px; + margin: 0 18px; @media (max-width: 475px) { - margin: 0 20px; + margin: 0 10px; } @media (max-width: 420px) { margin: 0 12px; @@ -114,7 +116,7 @@ } background-color: ${props => props.theme.footer.background}; border: none; - font-size: 12px; + font-size: 10px; font-weight: bold; .anticon { display: block; @@ -274,6 +276,13 @@ /> )} /> + + + @@ -306,6 +315,13 @@ Send + history.push('/tools')} + > + + Tools + history.push('/configure')} diff --git a/web/cashtab/package-lock.json b/web/cashtab/package-lock.json --- a/web/cashtab/package-lock.json +++ b/web/cashtab/package-lock.json @@ -18,6 +18,7 @@ "ecashaddrjs": "^1.0.1", "ecies-lite": "^1.0.7", "ethereum-blockies-base64": "^1.0.2", + "etoken-list": "^1.0.1", "localforage": "^1.9.0", "lodash.isempty": "^4.4.0", "lodash.isequal": "^4.5.0", @@ -4987,7 +4988,6 @@ }, "node_modules/base64-js": { "version": "1.5.1", - "dev": true, "funding": [ { "type": "github", @@ -8121,9 +8121,9 @@ } }, "node_modules/ecashaddrjs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ecashaddrjs/-/ecashaddrjs-1.0.1.tgz", - "integrity": "sha512-yozijdSLtuzi0vBj1BJ7MfAy/l7HQSGBfog9Ad+8M5sKT2gkSg5fTx0Rwh1z9heJJunagHXJ8DGsziPH8BKpeQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ecashaddrjs/-/ecashaddrjs-1.0.7.tgz", + "integrity": "sha512-KsvHYLlYtLr/GBkEPiwwQDIDBzqRx61qC34n1puHKOjVE4Uwg3syHccjFCqNynLa6T6xI0Rd7ByCRUJcuJcoIw==", "dependencies": { "big-integer": "1.6.36" } @@ -9110,6 +9110,73 @@ "pnglib": "0.0.1" } }, + "node_modules/etoken-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/etoken-list/-/etoken-list-1.0.1.tgz", + "integrity": "sha512-k64wg2JVWmAdOwMggZswidnL9jD3qRUW2Tvo1s03ubIhyx/vYSw8LrxpKmor67x6h31EdzR0TD2pEYrFj7ra7w==", + "dependencies": { + "axios": "^0.19.2", + "big.js": "^5.2.2", + "buffer": "^5.6.0", + "ecashaddrjs": "^1.0.6" + } + }, + "node_modules/etoken-list/node_modules/axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "deprecated": "Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410", + "dependencies": { + "follow-redirects": "1.5.10" + } + }, + "node_modules/etoken-list/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/etoken-list/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/etoken-list/node_modules/follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "dependencies": { + "debug": "=3.1.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etoken-list/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, "node_modules/eventemitter3": { "version": "4.0.7", "dev": true, @@ -11113,7 +11180,6 @@ }, "node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", @@ -31983,8 +32049,7 @@ } }, "base64-js": { - "version": "1.5.1", - "dev": true + "version": "1.5.1" }, "batch": { "version": "0.6.1", @@ -34203,9 +34268,9 @@ } }, "ecashaddrjs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ecashaddrjs/-/ecashaddrjs-1.0.1.tgz", - "integrity": "sha512-yozijdSLtuzi0vBj1BJ7MfAy/l7HQSGBfog9Ad+8M5sKT2gkSg5fTx0Rwh1z9heJJunagHXJ8DGsziPH8BKpeQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ecashaddrjs/-/ecashaddrjs-1.0.7.tgz", + "integrity": "sha512-KsvHYLlYtLr/GBkEPiwwQDIDBzqRx61qC34n1puHKOjVE4Uwg3syHccjFCqNynLa6T6xI0Rd7ByCRUJcuJcoIw==", "requires": { "big-integer": "1.6.36" } @@ -34856,6 +34921,57 @@ "pnglib": "0.0.1" } }, + "etoken-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/etoken-list/-/etoken-list-1.0.1.tgz", + "integrity": "sha512-k64wg2JVWmAdOwMggZswidnL9jD3qRUW2Tvo1s03ubIhyx/vYSw8LrxpKmor67x6h31EdzR0TD2pEYrFj7ra7w==", + "requires": { + "axios": "^0.19.2", + "big.js": "^5.2.2", + "buffer": "^5.6.0", + "ecashaddrjs": "^1.0.6" + }, + "dependencies": { + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, "eventemitter3": { "version": "4.0.7", "dev": true @@ -36282,8 +36398,7 @@ "requires": {} }, "ieee754": { - "version": "1.2.1", - "dev": true + "version": "1.2.1" }, "iferr": { "version": "0.1.5", diff --git a/web/cashtab/package.json b/web/cashtab/package.json --- a/web/cashtab/package.json +++ b/web/cashtab/package.json @@ -14,6 +14,7 @@ "ecashaddrjs": "^1.0.1", "ecies-lite": "^1.0.7", "ethereum-blockies-base64": "^1.0.2", + "etoken-list": "^1.0.1", "localforage": "^1.9.0", "lodash.isempty": "^4.4.0", "lodash.isequal": "^4.5.0", diff --git a/web/cashtab/src/components/App.js b/web/cashtab/src/components/App.js --- a/web/cashtab/src/components/App.js +++ b/web/cashtab/src/components/App.js @@ -10,11 +10,13 @@ CaretRightOutlined, SettingFilled, AppstoreAddOutlined, + UngroupOutlined, } from '@ant-design/icons'; import Wallet from '@components/Wallet/Wallet'; import Tokens from '@components/Tokens/Tokens'; import Send from '@components/Send/Send'; import SendToken from '@components/Send/SendToken'; +import Tools from '@components/Tools/Tools'; import Configure from '@components/Configure/Configure'; import NotFound from '@components/NotFound'; import CashTab from '@assets/cashtab_xec.png'; @@ -106,10 +108,10 @@ outline: none; } cursor: pointer; - padding: 24px 12px 12px 12px; - margin: 0 28px; + padding: 24px 6px 12px 12px; + margin: 0 18px; @media (max-width: 475px) { - margin: 0 20px; + margin: 0 10px; } @media (max-width: 420px) { margin: 0 12px; @@ -119,7 +121,7 @@ } background-color: ${props => props.theme.footer.background}; border: none; - font-size: 10.5px; + font-size: 10px; font-weight: bold; .anticon { display: block; @@ -306,6 +308,13 @@ /> )} /> + + + @@ -339,6 +348,13 @@ Send + history.push('/tools')} + > + + Tools + history.push('/configure')} diff --git a/web/cashtab/src/components/Common/Notifications.js b/web/cashtab/src/components/Common/Notifications.js --- a/web/cashtab/src/components/Common/Notifications.js +++ b/web/cashtab/src/components/Common/Notifications.js @@ -170,6 +170,15 @@ }); }; +const generalNotification = msg => { + const notificationStyle = getDeviceNotificationStyle(); + notification.success({ + message: msg, + icon: , + style: notificationStyle, + }); +}; + export { sendXecNotification, createTokenNotification, @@ -179,4 +188,5 @@ eTokenReceivedNotification, errorNotification, messageSignedNotification, + generalNotification, }; diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -22,6 +22,7 @@ tokenLogo: tokenLogo, tokenPrefixes: ['etoken'], tokenIconsUrl: 'https://etoken-icons.s3.us-west-2.amazonaws.com', + tokenDbUrl: 'https://tokendb.kingbch.com', txHistoryCount: 10, xecApiBatchSize: 20, defaultSettings: { fiatCurrency: 'usd' }, 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 @@ -166,6 +166,15 @@ }); } + // if this was routed from the Tool screen's Dividend Calculator then + // switch to multiple recipient mode and prepopulate the recipients field + if (location && location.state && location.state.dividendRecipients) { + setIsOneToManyXECSend(true); + setFormData({ + address: location.state.dividendRecipients, + }); + } + // Do not set txInfo in state if query strings are not present if ( !window.location || 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,409 @@ +import React, { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +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, Radio } 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, + convertToEcashAddr, + fromSmallestDenomination, +} from '@utils/cashMethods'; +import styled from 'styled-components'; +import { + isValidTokenId, + isValidXecDividend, + isValidDividendOutputsArray, +} from '@utils/validation'; +import * as etokenList from 'etoken-list'; + +const DistributionRatio = styled.div` + float: left; + color: black; +`; + +const StyledLink = styled(Link)` + color: #0074c2; + text-decoration: none; + padding: 8px; + position: relative; + border: solid 1px silver; + border-radius: 10px; +`; + +// 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; + // Modal settings + + const [bchObj, setBchObj] = useState(false); + + // 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(() => { + // 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); + checkCalculateDividendBtn(); + }, []); + + const [formData, setFormData] = useState({ + value: '', + address: '', + }); + + const [tokenId, setTokenId] = useState(''); + const [tokenIdIsValid, setTokenIdIsValid] = useState(null); + const [totalDividend, setTotalDividend] = useState(''); + const [totalDividendIsValid, setTotalDividendIsValid] = useState(null); + const [dividendRecipients, setDividendRecipients] = useState(''); + const [prorataDividend, setProrataDividend] = useState(true); + const [showCalculateDividendBtn, setShowCalculateDividendBtn] = + useState(false); + const [dividendOutputIsValid, setDividendOutputIsValid] = useState(true); + const [etokenHolders, setEtokenHolders] = useState('prorata'); + + const { getBCH } = useBCH(); + + const handleTokenIdInput = e => { + const { value } = e.target; + + setTokenIdIsValid(isValidTokenId(value)); + setTokenId(value); + checkCalculateDividendBtn(); + }; + + const handleTotalDividendInput = e => { + const { value } = e.target; + + setTotalDividendIsValid(isValidXecDividend(value)); + setTotalDividend(value); + checkCalculateDividendBtn(); + }; + + const handleDividendRatioInput = e => { + const { value } = e.target; + if (value === 'prorata') { + setProrataDividend(true); + } else { + setProrataDividend(false); + } + + checkCalculateDividendBtn(); + }; + + const checkCalculateDividendBtn = () => { + let showButton = tokenIdIsValid && totalDividendIsValid; + + // if the above fields are valid, enable the calculate dividend button + setShowCalculateDividendBtn(showButton); + }; + + function handlePaste(event) { + event.preventDefault(); + } + + const calculateXecDividend = async () => { + passLoadingStatus(true); + generalNotification( + 'Querying the blockchain, please wait... (this takes approx. 10 seconds)', + ); + + const latestBlock = await getLatestBlockHeight(bchObj); + + 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; + } + + let totalTokenAmongstRecipients = BigNumber(0); + let totalHolders = 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( + BigNumber(index), + )), + ); + + let circToDivRatio = BigNumber(totalDividend).div( + totalTokenAmongstRecipients, + ); + + let resultString = ''; + if (prorataDividend) { + dividendList.forEach( + (element, index) => + (resultString += + convertToEcashAddr(index) + + ',' + + BigNumber(element).multipliedBy(circToDivRatio) + + '\n'), + ); + } else { + dividendList.forEach( + (element, index) => + (resultString += + convertToEcashAddr(index) + + ',' + + BigNumber(totalDividend).div(totalHolders) + + '\n'), + ); + } + + resultString = resultString.substring(0, resultString.length - 1); // remove the final newline + setDividendRecipients(resultString); + + if (!resultString) { + errorNotification( + null, + 'No eToken holders found for Token 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)); + + passLoadingStatus(false); + }; + + return ( + <> + {!balances.totalBalance ? ( + '' + ) : ( + <> + + {fiatPrice !== null && !isNaN(balances.totalBalance) && ( + + )} + + )} + + + + + +
+ +
+ + + Distribution Ratio:    + + handleDividendRatioInput(e) + } + value={ + prorataDividend + ? 'prorata' + : 'equal' + } + > + + Pro Rata + + + Equal + + + + + + + handleTokenIdInput(e) + } + /> + + + + handleTotalDividendInput(e) + } + /> + + + One to Many Dividend Payment Outputs +