diff --git a/web/cashtab/src/components/Airdrop/Airdrop.js b/web/cashtab/src/components/Airdrop/Airdrop.js
index d91d48f84..6967c9efa 100644
--- a/web/cashtab/src/components/Airdrop/Airdrop.js
+++ b/web/cashtab/src/components/Airdrop/Airdrop.js
@@ -1,843 +1,873 @@
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 [equalDistributionRatio, setEqualDistributionRatio] = useState(false);
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 circToAirdropRatio = new BigNumber(0);
let resultString = '';
- airdropList.forEach(
- (element, index) =>
- (resultString +=
- convertEtokenToEcashAddr(index) +
- ',' +
- new BigNumber(element)
- .multipliedBy(circToAirdropRatio)
- .decimalPlaces(currency.cashDecimals) +
- '\n'),
- );
+ // generate the resulting recipients list based on distribution ratio
+ if (equalDistributionRatio) {
+ const equalDividend = new BigNumber(
+ formData.totalAirdrop,
+ ).dividedBy(new BigNumber(totalHolders));
+ airdropList.forEach(
+ (element, index) =>
+ (resultString +=
+ convertEtokenToEcashAddr(index) +
+ ',' +
+ equalDividend.decimalPlaces(currency.cashDecimals) +
+ '\n'),
+ );
+ } else {
+ circToAirdropRatio = new BigNumber(formData.totalAirdrop).div(
+ totalTokenAmongstRecipients,
+ );
+ 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)
}
/>
+
+
+ {
+ setEqualDistributionRatio(
+ prev => !prev,
+ );
+ }}
+ />
+
+
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
Copy to Send screen
Copy to Clipboard
>
)}
>
);
};
/*
passLoadingStatus must receive a default prop that is a function
in order to pass the rendering unit test in Airdrop.test.js
status => {console.log(status)} is an arbitrary stub function
*/
Airdrop.defaultProps = {
passLoadingStatus: status => {
console.log(status);
},
};
Airdrop.propTypes = {
jestBCH: PropTypes.object,
passLoadingStatus: PropTypes.func,
};
export default Airdrop;