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 @@ -17,6 +17,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", @@ -5024,7 +5025,6 @@ }, "node_modules/base64-js": { "version": "1.5.1", - "dev": true, "funding": [ { "type": "github", @@ -8161,9 +8161,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" } @@ -9341,6 +9341,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, @@ -11402,7 +11469,6 @@ }, "node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", @@ -32734,8 +32800,7 @@ } }, "base64-js": { - "version": "1.5.1", - "dev": true + "version": "1.5.1" }, "batch": { "version": "0.6.1", @@ -34958,9 +35023,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" } @@ -35724,6 +35789,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 @@ -37190,8 +37306,7 @@ "dev": true }, "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/assets/styles/theme.js b/web/cashtab/src/assets/styles/theme.js --- a/web/cashtab/src/assets/styles/theme.js +++ b/web/cashtab/src/assets/styles/theme.js @@ -79,6 +79,7 @@ hoverShadow: '0px 3px 10px -5px rgba(0, 0, 0, 0.75)', disabledOverlay: 'rgba(255, 255, 255, 0.5)', }, + styledLink: '#0074c2', }, collapses: { background: '#fbfcfd', 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/Atoms.js b/web/cashtab/src/components/Common/Atoms.js --- a/web/cashtab/src/components/Common/Atoms.js +++ b/web/cashtab/src/components/Common/Atoms.js @@ -1,4 +1,5 @@ import styled from 'styled-components'; +import { Link } from 'react-router-dom'; export const LoadingCtn = styled.div` width: 100%; @@ -61,3 +62,12 @@ font-size: 12px; } `; + +export const StyledLink = styled(Link)` + color: ${props => props.theme.buttons.styledLink}; + text-decoration: none; + padding: 8px; + position: relative; + border: solid 1px silver; + border-radius: 10px; +`; diff --git a/web/cashtab/src/components/Common/CustomIcons.js b/web/cashtab/src/components/Common/CustomIcons.js --- a/web/cashtab/src/components/Common/CustomIcons.js +++ b/web/cashtab/src/components/Common/CustomIcons.js @@ -72,3 +72,5 @@ ); + +export const CustomSpinner = ; 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/Configure/__tests__/__snapshots__/Configure.test.js.snap b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap --- a/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap +++ b/web/cashtab/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap @@ -2,7 +2,7 @@ exports[`Configure with a wallet 1`] = `

,
Signatures
, @@ -954,7 +954,7 @@ type="button" >
Switch to multiple recipients
@@ -1244,7 +1244,7 @@

0 @@ -1326,7 +1326,7 @@ ,
Signatures
, @@ -1411,7 +1411,7 @@ type="button" >
Switch to multiple recipients
@@ -1701,7 +1701,7 @@

0 @@ -1782,7 +1782,7 @@ ,
Signatures
, @@ -1867,7 +1867,7 @@ type="button" >
Switch to multiple recipients
@@ -2157,7 +2157,7 @@

0 @@ -2238,7 +2238,7 @@ ,
Signatures
, 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 @@ -367,13 +367,13 @@ onClick={[Function]} >
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,359 @@ +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) && ( + + )} + + )} + + + + + + + + + + + +
+ +
+ + + handleTokenIdInput(e) + } + /> + + + + handleTotalAirdropInput(e) + } + /> + + + + calculateXecAirdrop() + } + disabled={ + !airdropCalcInputIsValid || + !tokenIdIsValid + } + > + Calculate Airdrop + + + {showAirdropOutputs && ( + <> + {!airdropOutputIsValid && + etokenHolders > 0 && ( + <> + +
+ + )} + + One to Many Airdrop Payment + Outputs +