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 @@ -7,6 +7,7 @@ SendIcon, ReceiveIcon, SettingsIcon, + AirdropIcon, } from '@components/Common/CustomIcons'; import '../index.css'; import styled, { ThemeProvider, createGlobalStyle } from 'styled-components'; @@ -16,6 +17,7 @@ import Tokens from '@components/Tokens/Tokens'; import Send from '@components/Send/Send'; import SendToken from '@components/Send/SendToken'; +import Airdrop from '@components/Airdrop/Airdrop'; import Configure from '@components/Configure/Configure'; import NotFound from '@components/NotFound'; import CashTab from '@assets/cashtab_xec.png'; @@ -293,6 +295,13 @@ /> )} /> + + + @@ -325,6 +334,12 @@ > + history.push('/airdrop')} + > + + 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 @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "cashtab", "version": "1.0.0", "dependencies": { "@ant-design/icons": "^4.3.0", @@ -17,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", @@ -1892,7 +1894,6 @@ "jest-resolve": "^26.6.2", "jest-util": "^26.6.2", "jest-worker": "^26.6.2", - "node-notifier": "^8.0.0", "slash": "^3.0.0", "source-map": "^0.6.0", "string-length": "^4.0.1", @@ -3528,7 +3529,6 @@ "version": "0.8.0", "license": "MIT", "dependencies": { - "text-encoding": "^0.6.4", "ts-custom-error": "^2.2.1" }, "optionalDependencies": { @@ -3908,7 +3908,6 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dependencies": { - "graceful-fs": "^4.1.6", "universalify": "^2.0.0" }, "optionalDependencies": { @@ -3961,7 +3960,6 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dependencies": { - "graceful-fs": "^4.1.6", "universalify": "^2.0.0" }, "optionalDependencies": { @@ -5024,7 +5022,6 @@ }, "node_modules/base64-js": { "version": "1.5.1", - "dev": true, "funding": [ { "type": "github", @@ -5874,7 +5871,6 @@ "dependencies": { "anymatch": "~3.1.1", "braces": "~3.0.2", - "fsevents": "~2.3.1", "glob-parent": "~5.1.0", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", @@ -8161,9 +8157,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" } @@ -8503,8 +8499,7 @@ "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" + "optionator": "^0.8.1" }, "bin": { "escodegen": "bin/escodegen.js", @@ -9341,6 +9336,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, @@ -10732,7 +10794,6 @@ "minimist": "^1.2.5", "neo-async": "^2.6.0", "source-map": "^0.6.1", - "uglify-js": "^3.1.4", "wordwrap": "^1.0.0" }, "bin": { @@ -11402,7 +11463,6 @@ }, "node_modules/ieee754": { "version": "1.2.1", - "dev": true, "funding": [ { "type": "github", @@ -13062,8 +13122,7 @@ "esprima": "^4.0.1", "estraverse": "^4.2.0", "esutils": "^2.0.2", - "optionator": "^0.8.1", - "source-map": "~0.6.1" + "optionator": "^0.8.1" }, "bin": { "escodegen": "bin/escodegen.js", @@ -13202,7 +13261,6 @@ "@jest/types": "^24.9.0", "anymatch": "^2.0.0", "fb-watchman": "^2.0.0", - "fsevents": "^1.2.7", "graceful-fs": "^4.1.15", "invariant": "^2.2.4", "jest-serializer": "^24.9.0", @@ -13712,7 +13770,6 @@ "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", - "fsevents": "^2.1.2", "graceful-fs": "^4.2.4", "jest-regex-util": "^26.0.0", "jest-serializer": "^26.6.2", @@ -15039,9 +15096,6 @@ "version": "4.0.0", "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.6" - }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -15184,13 +15238,6 @@ "license": "Apache-2.0", "dependencies": { "copy-anything": "^2.0.1", - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "native-request": "^1.0.5", - "source-map": "~0.6.0", "tslib": "^1.10.0" }, "bin": { @@ -27048,10 +27095,8 @@ "dev": true, "license": "MIT", "dependencies": { - "chokidar": "^3.4.1", "graceful-fs": "^4.1.2", - "neo-async": "^2.5.0", - "watchpack-chokidar2": "^2.0.1" + "neo-async": "^2.5.0" }, "optionalDependencies": { "chokidar": "^3.4.1", @@ -27140,7 +27185,6 @@ "anymatch": "^2.0.0", "async-each": "^1.0.1", "braces": "^2.3.2", - "fsevents": "^1.2.7", "glob-parent": "^3.1.0", "inherits": "^2.0.3", "is-binary-path": "^1.0.0", @@ -27598,7 +27642,6 @@ "anymatch": "^2.0.0", "async-each": "^1.0.1", "braces": "^2.3.2", - "fsevents": "^1.2.7", "glob-parent": "^3.1.0", "inherits": "^2.0.3", "is-binary-path": "^1.0.0", @@ -32734,8 +32777,7 @@ } }, "base64-js": { - "version": "1.5.1", - "dev": true + "version": "1.5.1" }, "batch": { "version": "0.6.1", @@ -34958,9 +35000,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 +35766,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 +37283,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/airdrop-icon.svg b/web/cashtab/src/assets/airdrop-icon.svg new file mode 100644 --- /dev/null +++ b/web/cashtab/src/assets/airdrop-icon.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + 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 @@ -55,6 +55,7 @@ hoverShadow: '0px 3px 10px -5px rgba(0, 0, 0, 0.75)', disabledOverlay: 'rgba(255, 255, 255, 0.5)', }, + styledLink: '#ffffff', }, collapses: { background: '#255173', diff --git a/web/cashtab/src/components/Airdrop/Airdrop.js b/web/cashtab/src/components/Airdrop/Airdrop.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Airdrop/Airdrop.js @@ -0,0 +1,468 @@ +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, + 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 Airdrop = ({ 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% + + let latestBlock; + try { + latestBlock = await bchObj.Blockchain.getBlockCount(); + } catch (err) { + errorNotification( + err, + 'Error retrieving latest block height', + 'bchObj.Blockchain.getBlockCount() error', + ); + setIsAirdropCalcModalVisible(false); + passLoadingStatus(false); + return; + } + + setAirdropCalcModalProgress(50); + + etokenList.Config.SetUrl(currency.tokenDbUrl); + + let airdropList; + try { + airdropList = await etokenList.List.GetAddressListFor( + formData.tokenId, + latestBlock, + true, + ); + } catch (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) + .decimalPlaces(currency.cashDecimals) + + '\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 +