diff --git a/web/cashtab/src/components/Airdrop/Airdrop.js b/web/cashtab/src/components/Airdrop/Airdrop.js index 4483f1276..d91d48f84 100644 --- a/web/cashtab/src/components/Airdrop/Airdrop.js +++ b/web/cashtab/src/components/Airdrop/Airdrop.js @@ -1,843 +1,843 @@ 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, DestinationAddressMulti, InputAmountSingle, } from 'components/Common/EnhancedInputs'; import { CustomCollapseCtn } from 'components/Common/StyledCollapse'; import { Form, Alert, Input, Modal, Spin, Progress } from 'antd'; const { TextArea } = Input; import { Row, Col, Switch } from 'antd'; import { SmartButton } from 'components/Common/PrimaryButton'; import { errorNotification } from 'components/Common/Notifications'; import { currency } from 'components/Common/Ticker.js'; import BalanceHeader from 'components/Common/BalanceHeader'; import BalanceHeaderFiat from 'components/Common/BalanceHeaderFiat'; import CopyToClipboard from 'components/Common/CopyToClipboard'; import { getWalletState, convertEtokenToEcashAddr, fromSatoshisToXec, convertToEcashPrefix, convertEcashtoEtokenAddr, } from 'utils/cashMethods'; import { isValidTokenId, isValidXecAirdrop, isValidAirdropOutputsArray, isValidAirdropExclusionArray, } 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; display: flex; justify-content: center; 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}; `} `; const AirdropOptions = styled.div` text-align: left; color: ${props => props.theme.contrast}; `; const StyledModal = styled(Modal)` .ant-progress-text { color: ${props => props.theme.lightWhite} !important; } `; // Note jestBCH is only used for unit tests; BCHJS must be mocked for jest const Airdrop = ({ jestBCH, passLoadingStatus }) => { const ContextValue = React.useContext(WalletContext); const { BCH, 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 activeBCH = jestBCH ? jestBCH : BCH; // set the BCH instance to state, for other functions to reference setBchObj(activeBCH); }, [BCH]); useEffect(() => { 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(parseInt(0)); const [showAirdropOutputs, setShowAirdropOutputs] = useState(false); const [ignoreOwnAddress, setIgnoreOwnAddress] = useState(false); const [ignoreRecipientsBelowDust, setIgnoreRecipientsBelowDust] = useState(false); const [ignoreMintAddress, setIgnoreMintAddress] = useState(false); // flag to reflect the exclusion list checkbox const [ignoreCustomAddresses, setIgnoreCustomAddresses] = useState(false); // the exclusion list values const [ignoreCustomAddressesList, setIgnoreCustomAddressesList] = useState(false); const [ ignoreCustomAddressesListIsValid, setIgnoreCustomAddressesListIsValid, ] = useState(false); const [ignoreCustomAddressListError, setIgnoreCustomAddressListError] = useState(false); // flag to reflect the ignore minimum etoken balance switch const [ignoreMinEtokenBalance, setIgnoreMinEtokenBalance] = useState(false); const [ignoreMinEtokenBalanceAmount, setIgnoreMinEtokenBalanceAmount] = useState(new BigNumber(0)); const [ ignoreMinEtokenBalanceAmountIsValid, setIgnoreMinEtokenBalanceAmountIsValid, ] = useState(false); const [ ignoreMinEtokenBalanceAmountError, setIgnoreMinEtokenBalanceAmountError, ] = useState(false); 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 handleMinEtokenBalanceChange = e => { const { value } = e.target; if (new BigNumber(value).gt(new BigNumber(0))) { setIgnoreMinEtokenBalanceAmountIsValid(true); setIgnoreMinEtokenBalanceAmountError(false); } else { setIgnoreMinEtokenBalanceAmountError( 'Minimum eToken balance must be greater than 0', ); setIgnoreMinEtokenBalanceAmountIsValid(false); } setIgnoreMinEtokenBalanceAmount(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 Ignore Own Address option is checked, then filter out from recipients list if (ignoreOwnAddress) { const ownEtokenAddress = convertToEcashPrefix( wallet.Path1899.slpAddress, ); airdropList.delete(ownEtokenAddress); } // if Ignore eToken Minter option is checked, then filter out from recipients list if (ignoreMintAddress) { // extract the eToken mint address let genesisTx; try { genesisTx = await bchObj.RawTransactions.getRawTransaction( formData.tokenId, true, ); } catch (err) { errorNotification( null, 'Unable to retrieve minting address for eToken ID: ' + formData.tokenId, 'getRawTransaction Error', ); setIsAirdropCalcModalVisible(false); passLoadingStatus(false); return; } const mintEcashAddress = convertToEcashPrefix( genesisTx.vout[1].scriptPubKey.addresses[0], ); //vout[0] is always the OP_RETURN output const mintEtokenAddress = convertEcashtoEtokenAddr(mintEcashAddress); // remove the mint address from the recipients list airdropList.delete(mintEtokenAddress); } // filter out addresses from the exclusion list if the option is checked if (ignoreCustomAddresses && ignoreCustomAddressesListIsValid) { const addressStringArray = ignoreCustomAddressesList.split(','); for (let i = 0; i < addressStringArray.length; i++) { airdropList.delete( convertEcashtoEtokenAddr(addressStringArray[i]), ); } } // if the minimum etoken balance option is enabled if (ignoreMinEtokenBalance) { const minEligibleBalance = ignoreMinEtokenBalanceAmount; // initial filtering of recipients with less than minimum eToken balance for (let [key, value] of airdropList) { if (new BigNumber(value).isLessThan(minEligibleBalance)) { airdropList.delete(key); } } } if (!airdropList) { errorNotification( null, 'No recipients found for tokenId ' + formData.tokenId, 'Airdrop Calculation Error', ); setIsAirdropCalcModalVisible(false); passLoadingStatus(false); return; } // if the ignore minimum payment threshold option is enabled if (ignoreRecipientsBelowDust) { // minimum airdrop threshold const minEligibleAirdrop = fromSatoshisToXec(currency.dustSats); // first calculation on expected pro rata airdrops let initialTotalTokenAmongstRecipients = new BigNumber(0); let initialTotalHolders = new BigNumber(airdropList.size); // amount of addresses that hold this eToken setEtokenHolders(initialTotalHolders); // keep a cumulative total of each eToken holding in each address in airdropList airdropList.forEach( index => (initialTotalTokenAmongstRecipients = initialTotalTokenAmongstRecipients.plus( new BigNumber(index), )), ); let initialCircToAirdropRatio = new BigNumber( formData.totalAirdrop, ).div(initialTotalTokenAmongstRecipients); // initial filtering of recipients with less than minimum payout amount for (let [key, value] of airdropList) { const proRataAirdrop = new BigNumber(value).multipliedBy( initialCircToAirdropRatio, ); if (proRataAirdrop.isLessThan(minEligibleAirdrop)) { airdropList.delete(key); } } // if the list becomes empty after initial filtering if (!airdropList) { errorNotification( null, 'No recipients after filtering minimum payouts', 'Airdrop Calculation Error', ); setIsAirdropCalcModalVisible(false); passLoadingStatus(false); return; } } setAirdropCalcModalProgress(75); let totalTokenAmongstRecipients = new BigNumber(0); let totalHolders = parseInt(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', ); setIsAirdropCalcModalVisible(false); passLoadingStatus(false); 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 handleIgnoreMinEtokenBalanceAmt = e => { setIgnoreMinEtokenBalance(e); }; const handleAirdropCalcModalCancel = () => { setIsAirdropCalcModalVisible(false); passLoadingStatus(false); }; const handleIgnoreOwnAddress = e => { setIgnoreOwnAddress(e); }; const handleIgnoreRecipientBelowDust = e => { setIgnoreRecipientsBelowDust(e); }; const handleIgnoreMintAddress = e => { setIgnoreMintAddress(e); }; const handleIgnoreCustomAddresses = e => { setIgnoreCustomAddresses(e); }; const handleIgnoreCustomAddressesList = e => { // if the checkbox is not checked then skip the input validation if (!ignoreCustomAddresses) { return; } let customAddressList = e.target.value; // remove all whitespaces via regex customAddressList = customAddressList.replace(/ /g, ''); // validate the exclusion list input const addressListIsValid = isValidAirdropExclusionArray(customAddressList); setIgnoreCustomAddressesListIsValid(addressListIsValid); if (!addressListIsValid) { setIgnoreCustomAddressListError( 'Invalid address detected in ignore list', ); } else { setIgnoreCustomAddressListError(false); // needs to be explicitly set in order to refresh the error state from prior invalidation } // commit the ignore list to state setIgnoreCustomAddressesList(customAddressList); }; let airdropCalcInputIsValid = tokenIdIsValid && totalAirdropIsValid; // if the ignore min etoken balance and exclusion list options are in use, add the relevant validation to the total pre-calculation validation if (ignoreMinEtokenBalance && ignoreCustomAddresses) { // both enabled airdropCalcInputIsValid = ignoreMinEtokenBalanceAmountIsValid && tokenIdIsValid && totalAirdropIsValid && ignoreCustomAddressesListIsValid; } else if (ignoreMinEtokenBalance && !ignoreCustomAddresses) { // ignore minimum etoken balance option only airdropCalcInputIsValid = ignoreMinEtokenBalanceAmountIsValid && tokenIdIsValid && totalAirdropIsValid; } else if (!ignoreMinEtokenBalance && ignoreCustomAddresses) { // ignore custom addresses only airdropCalcInputIsValid = tokenIdIsValid && totalAirdropIsValid && ignoreCustomAddressesListIsValid; } return ( <> {!balances.totalBalance ? ( You currently have 0 {currency.ticker}
Deposit some funds to use this feature
) : ( <> {fiatPrice !== null && ( )} )}


handleTokenIdInput(e) } /> handleTotalAirdropInput(e) } /> handleIgnoreOwnAddress( prev => !prev, ) } defaultunchecked="true" checked={ignoreOwnAddress} />  Ignore my own address handleIgnoreRecipientBelowDust( prev => !prev, ) } defaultunchecked="true" checked={ ignoreRecipientsBelowDust } />  Ignore airdrops below min. payment ( {fromSatoshisToXec( currency.dustSats, ).toString()}{' '} XEC) handleIgnoreMintAddress( prev => !prev, ) } defaultunchecked="true" checked={ignoreMintAddress} />  Ignore eToken minter address handleIgnoreMinEtokenBalanceAmt( prev => !prev, ) } defaultunchecked="true" checked={ignoreMinEtokenBalance} style={{ marginBottom: '5px', }} />  Minimum eToken holder balance {ignoreMinEtokenBalance && ( handleMinEtokenBalanceChange( e, ), value: ignoreMinEtokenBalanceAmount, }} /> )} handleIgnoreCustomAddresses( prev => !prev, ) } defaultunchecked="true" checked={ignoreCustomAddresses} style={{ marginBottom: '5px', }} />  Ignore custom addresses {ignoreCustomAddresses && ( handleIgnoreCustomAddressesList( e, ), required: ignoreCustomAddresses, disabled: !ignoreCustomAddresses, }} /> )} calculateXecAirdrop() } disabled={ !airdropCalcInputIsValid || !tokenIdIsValid } > Calculate Airdrop {showAirdropOutputs && ( <> {!ignoreRecipientsBelowDust && !airdropOutputIsValid && etokenHolders > 0 && ( <>
)} One to Many Airdrop Payment Outputs