diff --git a/web/cashtab/src/components/Airdrop/Airdrop.js b/web/cashtab/src/components/Airdrop/Airdrop.js
index 17d1d4a1a..19b3e224e 100644
--- a/web/cashtab/src/components/Airdrop/Airdrop.js
+++ b/web/cashtab/src/components/Airdrop/Airdrop.js
@@ -1,887 +1,875 @@
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 { getMintAddress } from 'utils/chronik';
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 Airdrop = ({ passLoadingStatus }) => {
const ContextValue = React.useContext(WalletContext);
const {
- BCH,
wallet,
fiatPrice,
cashtabSettings,
chronik,
changeCashtabSettings,
} = 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, chainInfo;
try {
chainInfo = await chronik.blockchainInfo();
latestBlock = chainInfo.tipHeight;
console.log(
'Calculating airdrop recipients as at block #' + latestBlock,
);
} catch (err) {
errorNotification(
err,
'Error retrieving latest block height',
'chronik.blockchainInfo() 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 mintEtokenAddress;
try {
mintEtokenAddress = await getMintAddress(
chronik,
- bchObj,
formData.tokenId,
);
} catch (err) {
console.log(`Error in getMintAddress`, err);
errorNotification(
null,
'Unable to retrieve minting address for eToken ID: ' +
formData.tokenId,
'getMintAddress Error',
);
setIsAirdropCalcModalVisible(false);
passLoadingStatus(false);
return;
}
// 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);
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(0);
let resultString = '';
// 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;
diff --git a/web/cashtab/src/components/Airdrop/__tests__/Airdrop.test.js b/web/cashtab/src/components/Airdrop/__tests__/Airdrop.test.js
index f78f45238..efca602e6 100644
--- a/web/cashtab/src/components/Airdrop/__tests__/Airdrop.test.js
+++ b/web/cashtab/src/components/Airdrop/__tests__/Airdrop.test.js
@@ -1,114 +1,108 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { ThemeProvider } from 'styled-components';
import { theme } from 'assets/styles/theme';
import Airdrop from 'components/Airdrop/Airdrop';
-import BCHJS from '@psf/bch-js';
import {
walletWithBalancesAndTokens,
walletWithBalancesMock,
walletWithoutBalancesMock,
walletWithBalancesAndTokensWithCorrectState,
} from '../../Home/__mocks__/walletAndBalancesMock';
import { BrowserRouter as Router } from 'react-router-dom';
import { WalletContext } from 'utils/context';
beforeEach(() => {
// Mock method not implemented in JSDOM
// See reference at https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});
test('Wallet without BCH balance', () => {
- const testBCH = new BCHJS();
const component = renderer.create(
-
+ ,
);
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
test('Wallet with BCH balances', () => {
- const testBCH = new BCHJS();
const component = renderer.create(
-
+ ,
);
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
test('Wallet with BCH balances and tokens', () => {
- const testBCH = new BCHJS();
const component = renderer.create(
-
+ ,
);
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
test('Wallet with BCH balances and tokens and state field', () => {
- const testBCH = new BCHJS();
const component = renderer.create(
-
+ ,
);
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
test('Without wallet defined', () => {
const withoutWalletDefinedMock = {
wallet: {},
balances: { totalBalance: 0 },
loading: false,
};
- const testBCH = new BCHJS();
const component = renderer.create(
-
+ ,
);
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
diff --git a/web/cashtab/src/utils/__tests__/chronik.test.js b/web/cashtab/src/utils/__tests__/chronik.test.js
index f382c56b3..79af3037c 100644
--- a/web/cashtab/src/utils/__tests__/chronik.test.js
+++ b/web/cashtab/src/utils/__tests__/chronik.test.js
@@ -1,908 +1,862 @@
import {
organizeUtxosByType,
getPreliminaryTokensArray,
finalizeTokensArray,
finalizeSlpUtxos,
getTokenStats,
flattenChronikTxHistory,
sortAndTrimChronikTxHistory,
parseChronikTx,
getMintAddress,
} from 'utils/chronik';
import {
mockChronikUtxos,
mockOrganizedUtxosByType,
mockPreliminaryTokensArray,
mockPreliminaryTokensArrayClone,
mockPreliminaryTokensArrayCloneClone,
mockChronikTxDetailsResponses,
mockFinalTokenArray,
mockFinalCachedTokenInfo,
mockPartialCachedTokenInfo,
mockPartialChronikTxDetailsResponses,
mockPreliminarySlpUtxos,
mockFinalizedSlpUtxos,
mockTokenInfoById,
} from '../__mocks__/chronikUtxos';
import {
mockChronikTokenResponse,
mockGetTokenStatsReturn,
} from '../__mocks__/mockChronikTokenStats';
import {
mockTxHistoryOfAllAddresses,
mockFlatTxHistoryNoUnconfirmed,
mockSortedTxHistoryNoUnconfirmed,
mockFlatTxHistoryWithUnconfirmed,
mockSortedFlatTxHistoryWithUnconfirmed,
mockFlatTxHistoryWithAllUnconfirmed,
mockSortedFlatTxHistoryWithAllUnconfirmed,
mockParseTxWallet,
lambdaIncomingXecTx,
lambdaOutgoingXecTx,
lambdaIncomingEtokenTx,
lambdaOutgoingEtokenTx,
eTokenGenesisTx,
receivedEtokenTxNineDecimals,
anotherMockParseTxWallet,
txHistoryTokenInfoById,
mockAirdropTx,
mockWalletWithPrivateKeys,
mockSentEncryptedTx,
mockReceivedEncryptedTx,
mockTokenBurnTx,
mockTokenBurnWithDecimalsTx,
mockReceivedEtokenTx,
mockSwapWallet,
mockSwapTx,
} from '../__mocks__/chronikTxHistory';
import {
mintingTxTabCash,
mintingAddressTabCash,
mintingAddressBchFormatTabCash,
mintingHash160TabCash,
mintingTxPoW,
mintingAddressPoW,
mintingAddressBchFormatPoW,
mintingHash160PoW,
mintingTxAlita,
mintingAddressAlita,
mintingAddressBchFormatAlita,
mintingHash160Alita,
mintingAddressBchFormatBuxSelfMint,
mintingAddressBuxSelfMint,
mintingHash160BuxSelfMint,
mintingTxBuxSelfMint,
} from '../__mocks__/chronikMintTxs';
import { ChronikClient } from 'chronik-client';
import { when } from 'jest-when';
import BCHJS from '@psf/bch-js';
it(`getTokenStats successfully returns a token stats object`, async () => {
// Initialize chronik
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
const tokenId =
'bb8e9f685a06a2071d82f757ce19201b4c8e5e96fbe186960a3d65aec83eab20';
/*
Mock the API response from chronik.token('tokenId') called
in getTokenStats()
*/
chronik.token = jest.fn();
when(chronik.token)
.calledWith(tokenId)
.mockResolvedValue(mockChronikTokenResponse);
expect(await getTokenStats(chronik, tokenId)).toStrictEqual(
mockGetTokenStatsReturn,
);
});
it(`organizeUtxosByType successfully splits a chronikUtxos array into slpUtxos and nonSlpUtxos`, () => {
expect(organizeUtxosByType(mockChronikUtxos)).toStrictEqual(
mockOrganizedUtxosByType,
);
const resultingOrganizedUtxosObject = organizeUtxosByType(mockChronikUtxos);
const { nonSlpUtxos, preliminarySlpUtxos } = resultingOrganizedUtxosObject;
const utxosWithUnexpectedKeys = [];
for (let i = 0; i < nonSlpUtxos.length; i += 1) {
// None of the objects in mockOrganizedUtxosByType.nonSlpUtxos should have the `slpToken` key
// Note: Some may have an `slpMeta` key, if the utxo is from a token burn
const nonSlpUtxo = nonSlpUtxos[i];
if ('slpToken' in nonSlpUtxo) {
console.log(`unexpected nonSlpUtxo!`, nonSlpUtxo);
utxosWithUnexpectedKeys.push(nonSlpUtxo);
}
}
for (let i = 0; i < preliminarySlpUtxos.length; i += 1) {
// All of the objects in mockOrganizedUtxosByType.slpUtxos should have the `slpMeta` and `slpToken` keys
const slpUtxo = preliminarySlpUtxos[i];
if (!('slpMeta' in slpUtxo) || !('slpToken' in slpUtxo)) {
console.log(`unexpected slpUtxo!`, slpUtxo);
utxosWithUnexpectedKeys.push(slpUtxo);
}
}
expect(utxosWithUnexpectedKeys.length).toBe(0);
// Length of organized utxos should match original
expect(preliminarySlpUtxos.length + nonSlpUtxos.length).toBe(
mockChronikUtxos.length,
);
});
it(`getPreliminaryTokensArray successfully returns an array of all tokenIds and token balances (not yet adjusted for token decimals)`, () => {
expect(
getPreliminaryTokensArray(mockOrganizedUtxosByType.preliminarySlpUtxos),
).toStrictEqual(mockPreliminaryTokensArray);
});
it(`finalizeTokensArray successfully returns finalTokenArray and cachedTokenInfoById even if no cachedTokenInfoById is provided`, async () => {
// Initialize chronik
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
/*
Mock the API response from chronik.tx('tokenId') called
in returnGetTokenInfoChronikPromise -- for each tokenId used
*/
chronik.tx = jest.fn();
for (let i = 0; i < mockChronikTxDetailsResponses.length; i += 1) {
when(chronik.tx)
.calledWith(mockChronikTxDetailsResponses[i].txid)
.mockResolvedValue(mockChronikTxDetailsResponses[i]);
}
expect(
await finalizeTokensArray(chronik, mockPreliminaryTokensArray),
).toStrictEqual({
finalTokenArray: mockFinalTokenArray,
updatedTokenInfoById: mockFinalCachedTokenInfo,
newTokensToCache: true,
});
});
it(`finalizeTokensArray successfully returns finalTokenArray and cachedTokenInfoById when called with all token info in cache`, async () => {
// Initialize chronik
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
expect(
await finalizeTokensArray(
chronik,
mockPreliminaryTokensArrayClone,
mockFinalCachedTokenInfo,
),
).toStrictEqual({
finalTokenArray: mockFinalTokenArray,
updatedTokenInfoById: mockFinalCachedTokenInfo,
newTokensToCache: false,
});
});
it(`updateCachedTokenInfoAndFinalizeTokensArray successfully returns finalTokenArray and cachedTokenInfoById when called with some token info in cache`, async () => {
// Initialize chronik
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
/*
Mock the API response from chronik.tx('tokenId') called
in returnGetTokenInfoChronikPromise -- for each tokenId used
*/
chronik.tx = jest.fn();
for (let i = 0; i < mockPartialChronikTxDetailsResponses.length; i += 1) {
when(chronik.tx)
.calledWith(mockPartialChronikTxDetailsResponses[i].txid)
.mockResolvedValue(mockPartialChronikTxDetailsResponses[i]);
}
expect(
await finalizeTokensArray(
chronik,
mockPreliminaryTokensArrayCloneClone,
mockPartialCachedTokenInfo,
),
).toStrictEqual({
finalTokenArray: mockFinalTokenArray,
updatedTokenInfoById: mockFinalCachedTokenInfo,
newTokensToCache: true,
});
});
it(`finalizeSlpUtxos successfully adds token quantity adjusted for token decimals to preliminarySlpUtxos`, async () => {
expect(
await finalizeSlpUtxos(mockPreliminarySlpUtxos, mockTokenInfoById),
).toStrictEqual(mockFinalizedSlpUtxos);
});
it(`flattenChronikTxHistory successfully combines the result of getTxHistoryChronik into a single array`, async () => {
expect(
await flattenChronikTxHistory(mockTxHistoryOfAllAddresses),
).toStrictEqual(mockFlatTxHistoryNoUnconfirmed);
});
it(`sortAndTrimChronikTxHistory successfully orders the result of flattenChronikTxHistory by blockheight and firstSeenTime if all txs are confirmed, and returns a result of expected length`, async () => {
expect(
await sortAndTrimChronikTxHistory(mockFlatTxHistoryNoUnconfirmed, 10),
).toStrictEqual(mockSortedTxHistoryNoUnconfirmed);
});
it(`sortAndTrimChronikTxHistory successfully orders the result of flattenChronikTxHistory by blockheight and firstSeenTime if some txs are confirmed and others unconfirmed, and returns a result of expected length`, async () => {
expect(
await sortAndTrimChronikTxHistory(mockFlatTxHistoryWithUnconfirmed, 10),
).toStrictEqual(mockSortedFlatTxHistoryWithUnconfirmed);
});
it(`sortAndTrimChronikTxHistory successfully orders the result of flattenChronikTxHistory by blockheight and firstSeenTime if all txs are unconfirmed, and returns a result of expected length`, async () => {
expect(
await sortAndTrimChronikTxHistory(
mockFlatTxHistoryWithAllUnconfirmed,
10,
),
).toStrictEqual(mockSortedFlatTxHistoryWithAllUnconfirmed);
});
it(`Successfully parses an incoming XEC tx`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvll3cvjwd',
);
expect(
parseChronikTx(
BCH,
lambdaIncomingXecTx,
mockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: true,
xecAmount: '42',
originatingHash160: '4e532257c01b310b3b5c1fd947c79a72addf8523',
isEtokenTx: false,
airdropFlag: false,
airdropTokenId: '',
decryptionSuccess: false,
isCashtabMessage: false,
isEncryptedMessage: false,
opReturnMessage: '',
replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6',
});
});
it(`Successfully parses an outgoing XEC tx`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9',
);
expect(
parseChronikTx(
BCH,
lambdaOutgoingXecTx,
mockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: false,
xecAmount: '222',
originatingHash160: '76458db0ed96fe9863fc1ccec9fa2cfab884b0f6',
isEtokenTx: false,
airdropFlag: false,
airdropTokenId: '',
decryptionSuccess: false,
isCashtabMessage: false,
isEncryptedMessage: false,
opReturnMessage: '',
replyAddress: 'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
});
});
it(`Successfully parses an incoming eToken tx`, () => {
const BCH = new BCHJS();
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvll3cvjwd',
);
expect(
parseChronikTx(
BCH,
lambdaIncomingEtokenTx,
mockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: true,
xecAmount: '5.46',
isEtokenTx: true,
isTokenBurn: false,
originatingHash160: '4e532257c01b310b3b5c1fd947c79a72addf8523',
slpMeta: {
tokenId:
'4bd147fc5d5ff26249a9299c46b80920c0b81f59a60e05428262160ebee0b0c3',
tokenType: 'FUNGIBLE',
txType: 'SEND',
},
genesisInfo: {
decimals: 0,
success: true,
tokenDocumentHash: '',
tokenDocumentUrl:
'https://www.who.int/emergencies/diseases/novel-coronavirus-2019/covid-19-vaccines',
tokenId:
'4bd147fc5d5ff26249a9299c46b80920c0b81f59a60e05428262160ebee0b0c3',
tokenName: 'Covid19 Lifetime Immunity',
tokenTicker: 'NOCOVID',
},
etokenAmount: '12',
airdropFlag: false,
airdropTokenId: '',
decryptionSuccess: false,
isCashtabMessage: false,
isEncryptedMessage: false,
opReturnMessage: '',
replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6',
});
});
it(`Successfully parses an outgoing eToken tx`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qpmytrdsakt0axrrlswvaj069nat3p9s7ct4lsf8k9',
);
expect(
parseChronikTx(
BCH,
lambdaOutgoingEtokenTx,
mockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: false,
xecAmount: '5.46',
isEtokenTx: true,
isTokenBurn: false,
originatingHash160: '76458db0ed96fe9863fc1ccec9fa2cfab884b0f6',
slpMeta: {
tokenId:
'4bd147fc5d5ff26249a9299c46b80920c0b81f59a60e05428262160ebee0b0c3',
tokenType: 'FUNGIBLE',
txType: 'SEND',
},
genesisInfo: {
decimals: 0,
success: true,
tokenDocumentHash: '',
tokenDocumentUrl:
'https://www.who.int/emergencies/diseases/novel-coronavirus-2019/covid-19-vaccines',
tokenId:
'4bd147fc5d5ff26249a9299c46b80920c0b81f59a60e05428262160ebee0b0c3',
tokenName: 'Covid19 Lifetime Immunity',
tokenTicker: 'NOCOVID',
},
etokenAmount: '17',
airdropFlag: false,
airdropTokenId: '',
decryptionSuccess: false,
isCashtabMessage: false,
isEncryptedMessage: false,
opReturnMessage: '',
replyAddress: 'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj',
});
});
it(`Successfully parses a genesis eToken tx`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr',
);
expect(
parseChronikTx(
BCH,
eTokenGenesisTx,
anotherMockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: false,
xecAmount: '0',
originatingHash160: '95e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d',
isEtokenTx: true,
isTokenBurn: false,
etokenAmount: '777.7777777',
slpMeta: {
tokenType: 'FUNGIBLE',
txType: 'GENESIS',
tokenId:
'cf601c56b58bc05a39a95374a4a865f0a8b56544ea937b30fb46315441717c50',
},
genesisInfo: {
decimals: 7,
success: true,
tokenDocumentHash: '',
tokenDocumentUrl: 'https://cashtab.com/',
tokenId:
'cf601c56b58bc05a39a95374a4a865f0a8b56544ea937b30fb46315441717c50',
tokenName: 'UpdateTest',
tokenTicker: 'UDT',
},
airdropFlag: false,
airdropTokenId: '',
opReturnMessage: '',
isCashtabMessage: false,
isEncryptedMessage: false,
decryptionSuccess: false,
replyAddress: 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035',
});
});
it(`Successfully parses a received eToken tx with 9 decimal places`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvll3cvjwd',
);
expect(
parseChronikTx(
BCH,
receivedEtokenTxNineDecimals,
anotherMockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: true,
xecAmount: '5.46',
originatingHash160: '4e532257c01b310b3b5c1fd947c79a72addf8523',
isEtokenTx: true,
isTokenBurn: false,
etokenAmount: '0.123456789',
slpMeta: {
tokenType: 'FUNGIBLE',
txType: 'SEND',
tokenId:
'acba1d7f354c6d4d001eb99d31de174e5cea8a31d692afd6e7eb8474ad541f55',
},
genesisInfo: {
decimals: 9,
success: true,
tokenDocumentHash: '',
tokenDocumentUrl: 'https://cashtabapp.com/',
tokenId:
'acba1d7f354c6d4d001eb99d31de174e5cea8a31d692afd6e7eb8474ad541f55',
tokenName: 'CashTabBits',
tokenTicker: 'CTB',
},
airdropFlag: false,
airdropTokenId: '',
opReturnMessage: '',
isCashtabMessage: false,
isEncryptedMessage: false,
decryptionSuccess: false,
replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6',
});
});
it(`Correctly parses a received airdrop transaction`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qp36z7k8xt7k4l5xnxeypg5mfqeyvvyduukc069ng6',
);
expect(
parseChronikTx(
BCH,
mockAirdropTx,
anotherMockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: true,
xecAmount: '5.69',
originatingHash160: '63a17ac732fd6afe8699b240a29b483246308de7',
isEtokenTx: false,
airdropFlag: true,
airdropTokenId:
'bdb3b4215ca0622e0c4c07655522c376eaa891838a82f0217fa453bb0595a37c',
opReturnMessage: 'evc token service holders air dropπ₯ππ₯β€ππ¬π¬ππ€΄',
isCashtabMessage: true,
isEncryptedMessage: false,
decryptionSuccess: false,
replyAddress: 'ecash:qp36z7k8xt7k4l5xnxeypg5mfqeyvvyduu04m37fwd',
});
});
it(`Correctly parses a sent encyrpted message transaction`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qrhxmjw5p72a3cgx5cect3h63q5erw0gfc4l80hyqu',
);
expect(
parseChronikTx(
BCH,
mockSentEncryptedTx,
mockWalletWithPrivateKeys,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: false,
xecAmount: '12',
originatingHash160: 'ee6dc9d40f95d8e106a63385c6fa882991b9e84e',
isEtokenTx: false,
airdropFlag: false,
airdropTokenId: '',
opReturnMessage: 'Only the message recipient can view this',
isCashtabMessage: true,
isEncryptedMessage: true,
decryptionSuccess: false,
replyAddress: 'ecash:qrhxmjw5p72a3cgx5cect3h63q5erw0gfcvjnyv7xt',
});
});
it(`Correctly parses a received encyrpted message transaction`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvll3cvjwd',
);
expect(
parseChronikTx(
BCH,
mockReceivedEncryptedTx,
mockWalletWithPrivateKeys,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: true,
xecAmount: '11',
originatingHash160: '4e532257c01b310b3b5c1fd947c79a72addf8523',
isEtokenTx: false,
airdropFlag: false,
airdropTokenId: '',
opReturnMessage: 'Test encrypted message',
isCashtabMessage: true,
isEncryptedMessage: true,
decryptionSuccess: true,
replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6',
});
});
it(`Correctly parses a token burn transaction`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr',
);
expect(
parseChronikTx(
BCH,
mockTokenBurnTx,
anotherMockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: false,
xecAmount: '0',
originatingHash160: '95e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d',
isEtokenTx: true,
isTokenBurn: true,
etokenAmount: '12',
slpMeta: {
tokenType: 'FUNGIBLE',
txType: 'SEND',
tokenId:
'4db25a4b2f0b57415ce25fab6d9cb3ac2bbb444ff493dc16d0615a11ad06c875',
},
genesisInfo: {
tokenTicker: 'LVV',
tokenName: 'Lambda Variant Variants',
tokenDocumentUrl: 'https://cashtabapp.com/',
tokenDocumentHash: '',
decimals: 0,
tokenId:
'4db25a4b2f0b57415ce25fab6d9cb3ac2bbb444ff493dc16d0615a11ad06c875',
success: true,
},
airdropFlag: false,
airdropTokenId: '',
opReturnMessage: '',
isCashtabMessage: false,
isEncryptedMessage: false,
decryptionSuccess: false,
replyAddress: 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035',
});
});
it(`Correctly parses a token burn transaction with decimal places`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qz2708636snqhsxu8wnlka78h6fdp77ar5ulhz04hr',
);
expect(
parseChronikTx(
BCH,
mockTokenBurnWithDecimalsTx,
anotherMockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: false,
xecAmount: '0',
originatingHash160: '95e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d',
isEtokenTx: true,
etokenAmount: '0.1234567',
isTokenBurn: true,
slpMeta: {
tokenType: 'FUNGIBLE',
txType: 'SEND',
tokenId:
'7443f7c831cdf2b2b04d5f0465ed0bcf348582675b0e4f17906438c232c22f3d',
},
genesisInfo: {
tokenTicker: 'WDT',
tokenName:
'Test Token With Exceptionally Long Name For CSS And Style Revisions',
tokenDocumentUrl:
'https://www.ImpossiblyLongWebsiteDidYouThinkWebDevWouldBeFun.org',
tokenDocumentHash:
'85b591c15c9f49531e39fcfeb2a5a26b2bd0f7c018fb9cd71b5d92dfb732d5cc',
decimals: 7,
tokenId:
'7443f7c831cdf2b2b04d5f0465ed0bcf348582675b0e4f17906438c232c22f3d',
success: true,
},
airdropFlag: false,
airdropTokenId: '',
opReturnMessage: '',
isCashtabMessage: false,
isEncryptedMessage: false,
decryptionSuccess: false,
replyAddress: 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035',
});
});
it(`Correctly parses received quantity for a received eToken address`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvll3cvjwd',
);
expect(
parseChronikTx(
BCH,
mockReceivedEtokenTx,
anotherMockParseTxWallet,
txHistoryTokenInfoById,
),
).toStrictEqual({
incoming: true,
xecAmount: '5.46',
originatingHash160: '4e532257c01b310b3b5c1fd947c79a72addf8523',
isEtokenTx: true,
etokenAmount: '0.123456789',
isTokenBurn: false,
slpMeta: {
tokenType: 'FUNGIBLE',
txType: 'SEND',
tokenId:
'acba1d7f354c6d4d001eb99d31de174e5cea8a31d692afd6e7eb8474ad541f55',
},
genesisInfo: {
tokenTicker: 'CTB',
tokenName: 'CashTabBits',
tokenDocumentUrl: 'https://cashtabapp.com/',
tokenDocumentHash: '',
decimals: 9,
tokenId:
'acba1d7f354c6d4d001eb99d31de174e5cea8a31d692afd6e7eb8474ad541f55',
success: true,
},
airdropFlag: false,
airdropTokenId: '',
opReturnMessage: '',
isCashtabMessage: false,
isEncryptedMessage: false,
decryptionSuccess: false,
replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6',
});
});
it(`Correctly parses an incoming eToken tx that send only XEC to the Cashtab user recipient`, () => {
const BCH = new BCHJS({
restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
});
// This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
BCH.Address.hash160ToCash = jest
.fn()
.mockReturnValue(
'bitcoincash:qznaw38py34zpunz8rs9zrac9kxlsnxg95m2sf5czz',
);
expect(
parseChronikTx(BCH, mockSwapTx, mockSwapWallet, txHistoryTokenInfoById),
).toStrictEqual({
incoming: true,
xecAmount: '10',
originatingHash160: '205c792fff2ffc891e986246760ee1079fa5a369',
isEtokenTx: false,
airdropFlag: false,
airdropTokenId: '',
opReturnMessage: '',
isCashtabMessage: false,
isEncryptedMessage: false,
decryptionSuccess: false,
replyAddress: 'ecash:qznaw38py34zpunz8rs9zrac9kxlsnxg95z8yz0zy4',
});
});
it(`getMintAddress successfully parses chronik.tx response to determine mint address for TabCash token`, async () => {
// Initialize chronik
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
- const BCH = new BCHJS({
- restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
- });
/*
Mock the API response from chronik.tx('tokenId') called
in returnGetTokenInfoChronikPromise -- for each tokenId used
*/
chronik.tx = jest.fn();
when(chronik.tx)
.calledWith(mintingTxTabCash.txid)
.mockResolvedValue(mintingTxTabCash);
- // This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
- BCH.Address.hash160ToCash = jest
- .fn()
- .mockReturnValue(mintingAddressBchFormatTabCash);
-
- expect(await getMintAddress(chronik, BCH, mintingTxTabCash.txid)).toBe(
+ expect(await getMintAddress(chronik, mintingTxTabCash.txid)).toBe(
mintingAddressTabCash,
);
-
- // spy on mintingHash160
- expect(BCH.Address.hash160ToCash).toHaveBeenCalledWith(
- mintingHash160TabCash,
- );
});
it(`getMintAddress successfully parses chronik.tx response to determine mint address for PoW token`, async () => {
// Initialize chronik
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
- const BCH = new BCHJS({
- restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
- });
/*
Mock the API response from chronik.tx('tokenId') called
in returnGetTokenInfoChronikPromise -- for each tokenId used
*/
chronik.tx = jest.fn();
when(chronik.tx)
.calledWith(mintingTxPoW.txid)
.mockResolvedValue(mintingTxPoW);
- // This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
- BCH.Address.hash160ToCash = jest
- .fn()
- .mockReturnValue(mintingAddressBchFormatPoW);
-
- expect(await getMintAddress(chronik, BCH, mintingTxPoW.txid)).toBe(
+ expect(await getMintAddress(chronik, mintingTxPoW.txid)).toBe(
mintingAddressPoW,
);
-
- // spy on mintingHash160
- expect(BCH.Address.hash160ToCash).toHaveBeenCalledWith(mintingHash160PoW);
});
it(`getMintAddress successfully parses chronik.tx response to determine mint address for Alita token`, async () => {
// Initialize chronik
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
- const BCH = new BCHJS({
- restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
- });
+
/*
Mock the API response from chronik.tx('tokenId') called
in returnGetTokenInfoChronikPromise -- for each tokenId used
*/
chronik.tx = jest.fn();
when(chronik.tx)
.calledWith(mintingTxAlita.txid)
.mockResolvedValue(mintingTxAlita);
- // This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
- BCH.Address.hash160ToCash = jest
- .fn()
- .mockReturnValue(mintingAddressBchFormatAlita);
-
- expect(await getMintAddress(chronik, BCH, mintingTxAlita.txid)).toBe(
+ expect(await getMintAddress(chronik, mintingTxAlita.txid)).toBe(
mintingAddressAlita,
);
-
- // spy on mintingHash160
- expect(BCH.Address.hash160ToCash).toHaveBeenCalledWith(mintingHash160Alita);
});
it(`getMintAddress successfully parses chronik.tx response to determine mint address for a BUX self minted token`, async () => {
// Initialize chronik
const chronik = new ChronikClient(
'https://FakeChronikUrlToEnsureMocksOnly.com',
);
- const BCH = new BCHJS({
- restURL: 'https://FakeBchApiUrlToEnsureMocksOnly.com',
- });
+
/*
Mock the API response from chronik.tx('tokenId') called
in returnGetTokenInfoChronikPromise -- for each tokenId used
*/
chronik.tx = jest.fn();
when(chronik.tx)
.calledWith(mintingTxBuxSelfMint.txid)
.mockResolvedValue(mintingTxBuxSelfMint);
- // This function needs to be mocked as bch-js functions that require Buffer types do not work in jest environment
- BCH.Address.hash160ToCash = jest
- .fn()
- .mockReturnValue(mintingAddressBchFormatBuxSelfMint);
-
- expect(await getMintAddress(chronik, BCH, mintingTxBuxSelfMint.txid)).toBe(
+ expect(await getMintAddress(chronik, mintingTxBuxSelfMint.txid)).toBe(
mintingAddressBuxSelfMint,
);
-
- // spy on mintingHash160
- expect(BCH.Address.hash160ToCash).toHaveBeenCalledWith(
- mintingHash160BuxSelfMint,
- );
});
diff --git a/web/cashtab/src/utils/chronik.js b/web/cashtab/src/utils/chronik.js
index c84079a1d..cbedeae80 100644
--- a/web/cashtab/src/utils/chronik.js
+++ b/web/cashtab/src/utils/chronik.js
@@ -1,955 +1,948 @@
// Chronik methods
import BigNumber from 'bignumber.js';
import { currency } from 'components/Common/Ticker';
import {
parseOpReturn,
convertToEncryptStruct,
getHashArrayFromWallet,
getUtxoWif,
convertEcashtoEtokenAddr,
- convertToEcashPrefix,
+ hash160ToAddress,
} from 'utils/cashMethods';
import ecies from 'ecies-lite';
import wif from 'wif';
import cashaddr from 'ecashaddrjs';
// Return false if do not get a valid response
export const getTokenStats = async (chronik, tokenId) => {
try {
// token attributes available via chronik's token() method
let tokenResponseObj = await chronik.token(tokenId);
const tokenDecimals = tokenResponseObj.slpTxData.genesisInfo.decimals;
// additional arithmetic to account for token decimals
// circulating supply not provided by chronik, calculate via totalMinted - totalBurned
tokenResponseObj.circulatingSupply = new BigNumber(
tokenResponseObj.tokenStats.totalMinted,
)
.minus(new BigNumber(tokenResponseObj.tokenStats.totalBurned))
.shiftedBy(-1 * tokenDecimals)
.toString();
tokenResponseObj.tokenStats.totalMinted = new BigNumber(
tokenResponseObj.tokenStats.totalMinted,
)
.shiftedBy(-1 * tokenDecimals)
.toString();
tokenResponseObj.initialTokenQuantity = new BigNumber(
tokenResponseObj.initialTokenQuantity,
)
.shiftedBy(-1 * tokenDecimals)
.toString();
tokenResponseObj.tokenStats.totalBurned = new BigNumber(
tokenResponseObj.tokenStats.totalBurned,
)
.shiftedBy(-1 * tokenDecimals)
.toString();
return tokenResponseObj;
} catch (err) {
console.log(
`Error fetching token stats for tokenId ${tokenId}: ` + err,
);
return false;
}
};
/*
Note: chronik.script('p2pkh', hash160).utxos(); is not readily mockable in jest
Hence it is necessary to keep this out of any functions that require unit testing
*/
export const getUtxosSingleHashChronik = async (chronik, hash160) => {
// Get utxos at a single address, which chronik takes in as a hash160
let utxos;
try {
utxos = await chronik.script('p2pkh', hash160).utxos();
if (utxos.length === 0) {
// Chronik returns an empty array if there are no utxos at this hash160
return [];
}
/* Chronik returns an array of with a single object if there are utxos at this hash 160
[
{
outputScript: ,
utxos:[{utxo}, {utxo}, ..., {utxo}]
}
]
*/
// Return only the array of utxos at this address
return utxos[0].utxos;
} catch (err) {
console.log(`Error in chronik.utxos(${hash160})`, err);
}
};
export const returnGetUtxosChronikPromise = (chronik, hash160AndAddressObj) => {
/*
Chronik thinks in hash160s, but people and wallets think in addresses
Add the address to each utxo
*/
return new Promise((resolve, reject) => {
getUtxosSingleHashChronik(chronik, hash160AndAddressObj.hash160).then(
result => {
for (let i = 0; i < result.length; i += 1) {
const thisUtxo = result[i];
thisUtxo.address = hash160AndAddressObj.address;
}
resolve(result);
},
err => {
reject(err);
},
);
});
};
export const getUtxosChronik = async (chronik, hash160sMappedToAddresses) => {
/*
Chronik only accepts utxo requests for one address at a time
Construct an array of promises for each address
Note: Chronik requires the hash160 of an address for this request
*/
const chronikUtxoPromises = [];
for (let i = 0; i < hash160sMappedToAddresses.length; i += 1) {
const thisPromise = returnGetUtxosChronikPromise(
chronik,
hash160sMappedToAddresses[i],
);
chronikUtxoPromises.push(thisPromise);
}
const allUtxos = await Promise.all(chronikUtxoPromises);
// Since each individual utxo has address information, no need to keep them in distinct arrays
// Combine into one array of all utxos
const flatUtxos = allUtxos.flat();
return flatUtxos;
};
export const organizeUtxosByType = chronikUtxos => {
/*
Convert chronik utxos (returned by getUtxosChronik function, above) to match
shape of existing slpBalancesAndUtxos object
This means sequestering eToken utxos from non-eToken utxos
For legacy reasons, the term "SLP" is still sometimes used to describe an eToken
So, SLP utxos === eToken utxos, it's just a semantics difference here
*/
const nonSlpUtxos = [];
const preliminarySlpUtxos = [];
for (let i = 0; i < chronikUtxos.length; i += 1) {
// Construct nonSlpUtxos and slpUtxos arrays
const thisUtxo = chronikUtxos[i];
if (typeof thisUtxo.slpToken !== 'undefined') {
preliminarySlpUtxos.push(thisUtxo);
} else {
nonSlpUtxos.push(thisUtxo);
}
}
return { preliminarySlpUtxos, nonSlpUtxos };
};
export const getPreliminaryTokensArray = preliminarySlpUtxos => {
// Iterate over the slpUtxos to create the 'tokens' object
let tokensById = {};
preliminarySlpUtxos.forEach(preliminarySlpUtxo => {
/*
Note that a wallet could have many eToken utxos all belonging to the same eToken
For example, a user could have 100 of a certain eToken, but this is composed of
four utxos, one for 17, one for 50, one for 30, one for 3
*/
// Start with the existing object for this particular token, if it exists
let token = tokensById[preliminarySlpUtxo.slpMeta.tokenId];
if (token) {
if (preliminarySlpUtxo.slpToken.amount) {
token.balance = token.balance.plus(
new BigNumber(preliminarySlpUtxo.slpToken.amount),
);
}
} else {
// If it does not exist, create it
token = {};
token.tokenId = preliminarySlpUtxo.slpMeta.tokenId;
if (preliminarySlpUtxo.slpToken.amount) {
token.balance = new BigNumber(
preliminarySlpUtxo.slpToken.amount,
);
} else {
token.balance = new BigNumber(0);
}
tokensById[preliminarySlpUtxo.slpMeta.tokenId] = token;
}
});
const preliminaryTokensArray = Object.values(tokensById);
return preliminaryTokensArray;
};
const returnGetTokenInfoChronikPromise = (chronik, tokenId) => {
/*
The chronik.tx(txid) API call returns extensive transaction information
For the purposes of finalizing token information, we only need the token metadata
This function returns a promise that extracts only this needed information from
the chronik.tx(txid) API call
In this way, calling Promise.all() on an array of tokenIds that lack metadata
will return an array with all required metadata
*/
return new Promise((resolve, reject) => {
chronik.tx(tokenId).then(
result => {
const thisTokenInfo = result.slpTxData.genesisInfo;
thisTokenInfo.tokenId = tokenId;
// You only want the genesis info for tokenId
resolve(thisTokenInfo);
},
err => {
reject(err);
},
);
});
};
export const processPreliminaryTokensArray = (
preliminaryTokensArray,
tokenInfoByTokenId,
) => {
/* Iterate over preliminaryTokensArray to
1 - Add slp metadata (token ticker, name, other metadata)
2 - Calculate the token balance. Token balance in
preliminaryTokensArray does not take into account the
decimal places of the token...so it is incorrect.
*/
const finalTokenArray = [];
for (let i = 0; i < preliminaryTokensArray.length; i += 1) {
const thisToken = preliminaryTokensArray[i];
const thisTokenId = thisToken.tokenId;
// Because tokenInfoByTokenId is indexed by tokenId, it's easy to reference
const thisTokenInfo = tokenInfoByTokenId[thisTokenId];
// The decimals are specifically needed to calculate the correct balance
const thisTokenDecimals = thisTokenInfo.decimals;
// Add info object to token
thisToken.info = thisTokenInfo;
// Update balance according to decimals
thisToken.balance = thisToken.balance.shiftedBy(-1 * thisTokenDecimals);
// Now that you have the metadata and the correct balance,
// preliminaryTokenInfo is finalTokenInfo
finalTokenArray.push(thisToken);
}
return finalTokenArray;
};
export const finalizeTokensArray = async (
chronik,
preliminaryTokensArray,
cachedTokenInfoById = {},
) => {
// Iterate over preliminaryTokensArray to determine what tokens you need to make API calls for
// Create an array of promises
// Each promise is a chronik API call to obtain token metadata for this token ID
const getTokenInfoPromises = [];
for (let i = 0; i < preliminaryTokensArray.length; i += 1) {
const thisTokenId = preliminaryTokensArray[i].tokenId;
// See if you already have this info in cachedTokenInfo
if (thisTokenId in cachedTokenInfoById) {
// If you already have this info in cache, do not create an API request for it
continue;
}
const thisTokenInfoPromise = returnGetTokenInfoChronikPromise(
chronik,
thisTokenId,
);
getTokenInfoPromises.push(thisTokenInfoPromise);
}
const newTokensToCache = getTokenInfoPromises.length > 0;
// Get all the token info you need
let tokenInfoArray = [];
try {
tokenInfoArray = await Promise.all(getTokenInfoPromises);
} catch (err) {
console.log(`Error in Promise.all(getTokenInfoPromises)`, err);
}
// Add the token info you received from those API calls to
// your token info cache object, cachedTokenInfoByTokenId
const updatedTokenInfoById = cachedTokenInfoById;
for (let i = 0; i < tokenInfoArray.length; i += 1) {
/* tokenInfoArray is an array of objects that look like
{
"tokenTicker": "ST",
"tokenName": "ST",
"tokenDocumentUrl": "developer.bitcoin.com",
"tokenDocumentHash": "",
"decimals": 0,
"tokenId": "bf24d955f59351e738ecd905966606a6837e478e1982943d724eab10caad82fd"
}
*/
const thisTokenInfo = tokenInfoArray[i];
const thisTokenId = thisTokenInfo.tokenId;
// Add this entry to updatedTokenInfoById
updatedTokenInfoById[thisTokenId] = thisTokenInfo;
}
// Now use cachedTokenInfoByTokenId object to finalize token info
// Split this out into a separate function so you can unit test
const finalTokenArray = processPreliminaryTokensArray(
preliminaryTokensArray,
updatedTokenInfoById,
);
return { finalTokenArray, updatedTokenInfoById, newTokensToCache };
};
export const finalizeSlpUtxos = (preliminarySlpUtxos, tokenInfoById) => {
// We need tokenQty in each slpUtxo to support transaction creation
// Add this info here
const finalizedSlpUtxos = [];
for (let i = 0; i < preliminarySlpUtxos.length; i += 1) {
const thisUtxo = preliminarySlpUtxos[i];
const thisTokenId = thisUtxo.slpMeta.tokenId;
const { decimals } = tokenInfoById[thisTokenId];
// Update balance according to decimals
thisUtxo.tokenQty = new BigNumber(thisUtxo.slpToken.amount)
.shiftedBy(-1 * decimals)
.toString();
// SLP utxos also require tokenId and decimals directly in the utxo object
// This is bad organization but necessary until bch-js is refactored
// https://github.com/Permissionless-Software-Foundation/bch-js/blob/master/src/slp/tokentype1.js#L217
thisUtxo.tokenId = thisTokenId;
thisUtxo.decimals = decimals;
finalizedSlpUtxos.push(thisUtxo);
}
return finalizedSlpUtxos;
};
export const flattenChronikTxHistory = txHistoryOfAllAddresses => {
// Create an array of all txs
let flatTxHistoryArray = [];
for (let i = 0; i < txHistoryOfAllAddresses.length; i += 1) {
const txHistoryResponseOfThisAddress = txHistoryOfAllAddresses[i];
const txHistoryOfThisAddress = txHistoryResponseOfThisAddress.txs;
flatTxHistoryArray = flatTxHistoryArray.concat(txHistoryOfThisAddress);
}
return flatTxHistoryArray;
};
export const sortAndTrimChronikTxHistory = (
flatTxHistoryArray,
txHistoryCount,
) => {
// Isolate unconfirmed txs
// In chronik, unconfirmed txs have an `undefined` block key
const unconfirmedTxs = [];
const confirmedTxs = [];
for (let i = 0; i < flatTxHistoryArray.length; i += 1) {
const thisTx = flatTxHistoryArray[i];
if (typeof thisTx.block === 'undefined') {
unconfirmedTxs.push(thisTx);
} else {
confirmedTxs.push(thisTx);
}
}
// Sort confirmed txs by blockheight, and then timeFirstSeen
const sortedConfirmedTxHistoryArray = confirmedTxs.sort(
(a, b) =>
// We want more recent blocks i.e. higher blockheights to have earlier array indices
b.block.height - a.block.height ||
// For blocks with the same height, we want more recent timeFirstSeen i.e. higher timeFirstSeen to have earlier array indices
b.timeFirstSeen - a.timeFirstSeen,
);
// Sort unconfirmed txs by timeFirstSeen
const sortedUnconfirmedTxHistoryArray = unconfirmedTxs.sort(
(a, b) => b.timeFirstSeen - a.timeFirstSeen,
);
// The unconfirmed txs are more recent, so they should be inserted into an array before the confirmed txs
const sortedChronikTxHistoryArray = sortedUnconfirmedTxHistoryArray.concat(
sortedConfirmedTxHistoryArray,
);
const trimmedAndSortedChronikTxHistoryArray =
sortedChronikTxHistoryArray.splice(0, txHistoryCount);
return trimmedAndSortedChronikTxHistoryArray;
};
export const returnGetTxHistoryChronikPromise = (
chronik,
hash160AndAddressObj,
) => {
/*
Chronik thinks in hash160s, but people and wallets think in addresses
Add the address to each utxo
*/
return new Promise((resolve, reject) => {
chronik
.script('p2pkh', hash160AndAddressObj.hash160)
.history(/*page=*/ 0, /*page_size=*/ currency.txHistoryCount)
.then(
result => {
resolve(result);
},
err => {
reject(err);
},
);
});
};
export const parseChronikTx = (BCH, tx, wallet, tokenInfoById) => {
const walletHash160s = getHashArrayFromWallet(wallet);
const { inputs, outputs } = tx;
// Assign defaults
let incoming = true;
let xecAmount = new BigNumber(0);
let originatingHash160 = '';
let etokenAmount = new BigNumber(0);
let isTokenBurn = false;
let isEtokenTx = 'slpTxData' in tx && typeof tx.slpTxData !== 'undefined';
const isGenesisTx =
isEtokenTx &&
tx.slpTxData.slpMeta &&
tx.slpTxData.slpMeta.txType &&
tx.slpTxData.slpMeta.txType === 'GENESIS';
// Initialize required variables
let substring = '';
let airdropFlag = false;
let airdropTokenId = '';
let opReturnMessage = '';
let isCashtabMessage = false;
let isEncryptedMessage = false;
let decryptionSuccess = false;
let replyAddress = '';
// Iterate over inputs to see if this is an incoming tx (incoming === true)
for (let i = 0; i < inputs.length; i += 1) {
const thisInput = inputs[i];
const thisInputSendingHash160 = thisInput.outputScript;
// If this is an etoken tx, check for token burn
if (
isEtokenTx &&
typeof thisInput.slpBurn !== 'undefined' &&
thisInput.slpBurn.token &&
thisInput.slpBurn.token.amount &&
thisInput.slpBurn.token.amount !== '0'
) {
// Assume that any eToken tx with a burn is a burn tx
isTokenBurn = true;
try {
const thisEtokenBurnAmount = new BigNumber(
thisInput.slpBurn.token.amount,
);
// Need to know the total output amount to compare to total input amount and tell if this is a burn transaction
etokenAmount = etokenAmount.plus(thisEtokenBurnAmount);
} catch (err) {
// do nothing
// If this happens, the burn amount will render wrong in tx history because we don't have the info in chronik
// This is acceptable
}
}
/*
Assume the first input is the originating address
https://en.bitcoin.it/wiki/Script for reference
Assume standard pay-to-pubkey-hash tx
scriptPubKey: OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG
76 + a9 + 14 = OP_DUP + OP_HASH160 + 14 Bytes to push
88 + ac = OP_EQUALVERIFY + OP_CHECKSIG
So, the hash160 we want will be in between '76a914' and '88ac'
...most of the time ;)
*/
try {
originatingHash160 = thisInputSendingHash160.substring(
thisInputSendingHash160.indexOf('76a914') + '76a914'.length,
thisInputSendingHash160.lastIndexOf('88ac'),
);
let replyAddressBchFormat =
BCH.Address.hash160ToCash(originatingHash160);
const { type, hash } = cashaddr.decode(replyAddressBchFormat);
replyAddress = cashaddr.encode('ecash', type, hash);
} catch (err) {
console.log(`err from ${originatingHash160}`, err);
// If the transaction is nonstandard, don't worry about a reply address for now
originatingHash160 = 'N/A';
}
for (let j = 0; j < walletHash160s.length; j += 1) {
const thisWalletHash160 = walletHash160s[j];
if (thisInputSendingHash160.includes(thisWalletHash160)) {
// Then this is an outgoing tx
incoming = false;
// Break out of this for loop once you know this is an incoming tx
break;
}
}
}
// Iterate over outputs to get the amount sent
for (let i = 0; i < outputs.length; i += 1) {
const thisOutput = outputs[i];
const thisOutputReceivedAtHash160 = thisOutput.outputScript;
// Check for OP_RETURN msg
if (
thisOutput.value === '0' &&
typeof thisOutput.slpToken === 'undefined'
) {
let hex = thisOutputReceivedAtHash160;
let parsedOpReturnArray = parseOpReturn(hex);
// Exactly copying lines 177-293 of useBCH.js
// Differences
// 1 - patched ecies not async error
// 2 - Removed if loop for tx being token, as this is handled elsewhere here
if (!parsedOpReturnArray) {
console.log(
'useBCH.parsedTxData() error: parsed array is empty',
);
break;
}
let message = '';
let txType = parsedOpReturnArray[0];
if (txType === currency.opReturn.appPrefixesHex.airdrop) {
// this is to facilitate special Cashtab-specific cases of airdrop txs, both with and without msgs
// The UI via Tx.js can check this airdropFlag attribute in the parsedTx object to conditionally render airdrop-specific formatting if it's true
airdropFlag = true;
// index 0 is drop prefix, 1 is the token Id, 2 is msg prefix, 3 is msg
airdropTokenId = parsedOpReturnArray[1];
txType = parsedOpReturnArray[2];
// remove the first two elements of airdrop prefix and token id from array so the array parsing logic below can remain unchanged
parsedOpReturnArray.splice(0, 2);
// index 0 now becomes msg prefix, 1 becomes the msg
}
if (txType === currency.opReturn.appPrefixesHex.cashtab) {
// this is a Cashtab message
try {
opReturnMessage = Buffer.from(
parsedOpReturnArray[1],
'hex',
);
isCashtabMessage = true;
} catch (err) {
// soft error if an unexpected or invalid cashtab hex is encountered
opReturnMessage = '';
console.log(
'useBCH.parsedTxData() error: invalid cashtab msg hex: ' +
parsedOpReturnArray[1],
);
}
} else if (
txType === currency.opReturn.appPrefixesHex.cashtabEncrypted
) {
if (!incoming) {
// outgoing encrypted messages currently can not be decrypted by sender's wallet since the message is encrypted with the recipient's pub key
opReturnMessage =
'Only the message recipient can view this';
isCashtabMessage = true;
isEncryptedMessage = true;
continue; // skip to next output hex
}
// this is an encrypted Cashtab message
let msgString = parsedOpReturnArray[1];
let fundingWif, privateKeyObj, privateKeyBuff;
if (
wallet &&
wallet.state &&
wallet.state.slpBalancesAndUtxos &&
wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0]
) {
fundingWif = getUtxoWif(
wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0],
wallet,
);
privateKeyObj = wif.decode(fundingWif);
privateKeyBuff = privateKeyObj.privateKey;
if (!privateKeyBuff) {
isCashtabMessage = true;
isEncryptedMessage = true;
opReturnMessage = 'Private key extraction error';
continue; // skip to next output hex without triggering an API error
}
} else {
break;
}
let structData;
let decryptedMessage;
try {
// Convert the hex encoded message to a buffer
const msgBuf = Buffer.from(msgString, 'hex');
// Convert the bufer into a structured object.
structData = convertToEncryptStruct(msgBuf);
decryptedMessage = ecies.decrypt(
privateKeyBuff,
structData,
);
decryptionSuccess = true;
} catch (err) {
console.log(
'useBCH.parsedTxData() decryption error: ' + err,
);
decryptedMessage = 'Unable to decrypt this message';
}
isCashtabMessage = true;
isEncryptedMessage = true;
opReturnMessage = decryptedMessage;
} else {
// this is an externally generated message
message = txType; // index 0 is the message content in this instance
// if there are more than one part to the external message
const arrayLength = parsedOpReturnArray.length;
for (let i = 1; i < arrayLength; i++) {
message = message + parsedOpReturnArray[i];
}
try {
opReturnMessage = Buffer.from(message, 'hex');
} catch (err) {
// soft error if an unexpected or invalid cashtab hex is encountered
opReturnMessage = '';
console.log(
'useBCH.parsedTxData() error: invalid external msg hex: ' +
substring,
);
}
}
}
// Find amounts at your wallet's addresses
for (let j = 0; j < walletHash160s.length; j += 1) {
const thisWalletHash160 = walletHash160s[j];
if (thisOutputReceivedAtHash160.includes(thisWalletHash160)) {
// If incoming tx, this is amount received by the user's wallet
// if outgoing tx (incoming === false), then this is a change amount
const thisOutputAmount = new BigNumber(thisOutput.value);
xecAmount = incoming
? xecAmount.plus(thisOutputAmount)
: xecAmount.minus(thisOutputAmount);
// Parse token qty if token tx
// Note: edge case this is a token tx that sends XEC to Cashtab recipient but token somewhere else
if (isEtokenTx && !isTokenBurn) {
try {
const thisEtokenAmount = new BigNumber(
thisOutput.slpToken.amount,
);
etokenAmount =
incoming || isGenesisTx
? etokenAmount.plus(thisEtokenAmount)
: etokenAmount.minus(thisEtokenAmount);
} catch (err) {
// edge case described above; in this case there is zero eToken value for this Cashtab recipient in this output, so add 0
etokenAmount.plus(new BigNumber(0));
}
}
}
}
// Output amounts not at your wallet are sent amounts if !incoming
// Exception for eToken genesis transactions
if (!incoming) {
const thisOutputAmount = new BigNumber(thisOutput.value);
xecAmount = xecAmount.plus(thisOutputAmount);
if (isEtokenTx && !isGenesisTx && !isTokenBurn) {
try {
const thisEtokenAmount = new BigNumber(
thisOutput.slpToken.amount,
);
etokenAmount = etokenAmount.plus(thisEtokenAmount);
} catch (err) {
// NB the edge case described above cannot exist in an outgoing tx
// because the eTokens sent originated from this wallet
}
}
}
}
/* If it's an eToken tx that
- did not send any eTokens to the receiving Cashtab wallet
- did send XEC to the receiving Cashtab wallet
Parse it as an XEC received tx
This type of tx is created by this swap wallet. More detailed parsing to be added later as use case is better understood
https://www.youtube.com/watch?v=5EFWXHPwzRk
*/
if (isEtokenTx && etokenAmount.isEqualTo(0)) {
isEtokenTx = false;
opReturnMessage = '';
}
// Convert from sats to XEC
xecAmount = xecAmount.shiftedBy(-1 * currency.cashDecimals);
// Convert from BigNumber to string
xecAmount = xecAmount.toString();
// Get decimal info for correct etokenAmount
let genesisInfo = {};
if (isEtokenTx) {
// Get token genesis info from cache
let decimals = 0;
try {
genesisInfo = tokenInfoById[tx.slpTxData.slpMeta.tokenId];
if (typeof genesisInfo !== 'undefined') {
genesisInfo.success = true;
decimals = genesisInfo.decimals;
etokenAmount = etokenAmount.shiftedBy(-1 * decimals);
} else {
genesisInfo = { success: false };
}
} catch (err) {
console.log(
`Error getting token info from cache in parseChronikTx`,
err,
);
// To keep this function synchronous, do not get this info from the API if it is not in cache
// Instead, return a flag so that useWallet.js knows and can fetch this info + add it to cache
genesisInfo = { success: false };
}
}
etokenAmount = etokenAmount.toString();
// Convert opReturnMessage to string
opReturnMessage = Buffer.from(opReturnMessage).toString();
// Return eToken specific fields if eToken tx
if (isEtokenTx) {
const { slpMeta } = tx.slpTxData;
return {
incoming,
xecAmount,
originatingHash160,
isEtokenTx,
etokenAmount,
isTokenBurn,
slpMeta,
genesisInfo,
airdropFlag,
airdropTokenId,
opReturnMessage: '',
isCashtabMessage,
isEncryptedMessage,
decryptionSuccess,
replyAddress,
};
}
// Otherwise do not include these fields
return {
incoming,
xecAmount,
originatingHash160,
isEtokenTx,
airdropFlag,
airdropTokenId,
opReturnMessage,
isCashtabMessage,
isEncryptedMessage,
decryptionSuccess,
replyAddress,
};
};
export const getTxHistoryChronik = async (
chronik,
BCH,
wallet,
tokenInfoById,
) => {
// Create array of promises to get chronik history for each address
// Combine them all and sort by blockheight and firstSeen
// Add all the info cashtab needs to make them useful
const hash160AndAddressObjArray = [
{
address: wallet.Path145.cashAddress,
hash160: wallet.Path145.hash160,
},
{
address: wallet.Path245.cashAddress,
hash160: wallet.Path245.hash160,
},
{
address: wallet.Path1899.cashAddress,
hash160: wallet.Path1899.hash160,
},
];
let txHistoryPromises = [];
for (let i = 0; i < hash160AndAddressObjArray.length; i += 1) {
const txHistoryPromise = returnGetTxHistoryChronikPromise(
chronik,
hash160AndAddressObjArray[i],
);
txHistoryPromises.push(txHistoryPromise);
}
let txHistoryOfAllAddresses;
try {
txHistoryOfAllAddresses = await Promise.all(txHistoryPromises);
} catch (err) {
console.log(`Error in Promise.all(txHistoryPromises)`, err);
}
const flatTxHistoryArray = flattenChronikTxHistory(txHistoryOfAllAddresses);
const sortedTxHistoryArray = sortAndTrimChronikTxHistory(
flatTxHistoryArray,
currency.txHistoryCount,
);
// Parse txs
const chronikTxHistory = [];
const uncachedTokenIds = [];
for (let i = 0; i < sortedTxHistoryArray.length; i += 1) {
const sortedTx = sortedTxHistoryArray[i];
// Add token genesis info so parsing function can calculate amount by decimals
sortedTx.parsed = parseChronikTx(BCH, sortedTx, wallet, tokenInfoById);
// Check to see if this tx was a token tx with uncached tokenInfoById
if (
sortedTx.parsed.isEtokenTx &&
sortedTx.parsed.genesisInfo &&
!sortedTx.parsed.genesisInfo.success
) {
// Only add if the token id is not already in uncachedTokenIds
const uncachedTokenId = sortedTx.parsed.slpMeta.tokenId;
if (!uncachedTokenIds.includes(uncachedTokenId))
uncachedTokenIds.push(uncachedTokenId);
}
chronikTxHistory.push(sortedTx);
}
const txHistoryNewTokensToCache = uncachedTokenIds.length > 0;
if (!txHistoryNewTokensToCache) {
// This will almost always be the case
// Edge case to find uncached token info in tx history that was not caught in processing utxos
// Requires performing transactions in one wallet, then loading the same wallet in another browser later
return {
chronikTxHistory,
txHistoryUpdatedTokenInfoById: tokenInfoById,
txHistoryNewTokensToCache,
};
}
// Iterate over uncachedTokenIds to get genesis info and add to cache
const getTokenInfoPromises = [];
for (let i = 0; i < uncachedTokenIds.length; i += 1) {
const thisTokenId = uncachedTokenIds[i];
const thisTokenInfoPromise = returnGetTokenInfoChronikPromise(
chronik,
thisTokenId,
);
getTokenInfoPromises.push(thisTokenInfoPromise);
}
// Get all the token info you need
let tokenInfoArray = [];
try {
tokenInfoArray = await Promise.all(getTokenInfoPromises);
} catch (err) {
console.log(
`Error in Promise.all(getTokenInfoPromises) in getTxHistoryChronik`,
err,
);
}
// Add the token info you received from those API calls to
// your token info cache object, cachedTokenInfoByTokenId
const txHistoryUpdatedTokenInfoById = tokenInfoById;
for (let i = 0; i < tokenInfoArray.length; i += 1) {
/* tokenInfoArray is an array of objects that look like
{
"tokenTicker": "ST",
"tokenName": "ST",
"tokenDocumentUrl": "developer.bitcoin.com",
"tokenDocumentHash": "",
"decimals": 0,
"tokenId": "bf24d955f59351e738ecd905966606a6837e478e1982943d724eab10caad82fd"
}
*/
const thisTokenInfo = tokenInfoArray[i];
const thisTokenId = thisTokenInfo.tokenId;
// Add this entry to updatedTokenInfoById
txHistoryUpdatedTokenInfoById[thisTokenId] = thisTokenInfo;
}
return {
chronikTxHistory,
txHistoryUpdatedTokenInfoById,
txHistoryNewTokensToCache,
};
};
-export const getMintAddress = async (chronik, BCH, tokenId) => {
+export const getMintAddress = async (chronik, tokenId) => {
let genesisTx;
let mintingHash160;
try {
genesisTx = await chronik.tx(tokenId);
// get the minting address chronik
// iterate over the tx outputs
const { outputs } = genesisTx;
for (let i = 0; i < outputs.length; i += 1) {
const thisOutput = outputs[i];
// Check to see if this output has eTokens
if (
thisOutput &&
thisOutput.slpToken &&
typeof thisOutput.slpToken !== 'undefined' &&
thisOutput.slpToken.amount &&
Number(thisOutput.slpToken.amount) > 0
) {
// then this is the minting address
const thisOutputHash160 = thisOutput.outputScript;
mintingHash160 = thisOutputHash160.substring(
thisOutputHash160.indexOf('76a914') + '76a914'.length,
thisOutputHash160.lastIndexOf('88ac'),
);
}
}
- const mintingAdressBchFormat =
- BCH.Address.hash160ToCash(mintingHash160);
- const mintEcashAddressChronik = convertToEcashPrefix(
- mintingAdressBchFormat,
- );
- const mintEtokenAddressChronik = convertEcashtoEtokenAddr(
- mintEcashAddressChronik,
- );
- return mintEtokenAddressChronik;
+
+ return convertEcashtoEtokenAddr(hash160ToAddress(mintingHash160));
} catch (err) {
console.log(`Error in getMintAddress`, err);
return err;
}
};