diff --git a/web/cashtab/src/components/Home/Tx.js b/web/cashtab/src/components/Home/Tx.js index 2dd49af2f..0c21920f0 100644 --- a/web/cashtab/src/components/Home/Tx.js +++ b/web/cashtab/src/components/Home/Tx.js @@ -1,1115 +1,1097 @@ import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import { SendIcon, ReceiveIcon, GenesisIcon, UnparsedIcon, ThemedContactsOutlined, ThemedBurnOutlined, } from 'components/Common/CustomIcons'; import { currency } from 'components/Common/Ticker'; import { formatBalance, formatDate } from 'utils/formatting'; import TokenIcon from 'components/Tokens/TokenIcon'; import { Collapse } from 'antd'; import CopyToClipboard from 'components/Common/CopyToClipboard'; import { ThemedCopySolid, ThemedLinkSolid, ThemedPdfSolid, } from 'components/Common/CustomIcons'; const TxIcon = styled.div` svg { width: 20px; height: 20px; } height: 40px; width: 40px; border: 1px solid #fff; display: flex; align-items: center; justify-content: center; border-radius: 100px; `; const AddToContacts = styled.span` max-height: 200px; text-align: left; `; const SentTx = styled(TxIcon)` svg { margin-right: -3px; } fill: ${props => props.theme.contrast}; `; const BurnedTx = styled(TxIcon)` svg { margin-right: -3px; } border-color: ${props => props.theme.eCashPurple}; `; const ReceivedTx = styled(TxIcon)` svg { fill: ${props => props.theme.eCashBlue}; } border-color: ${props => props.theme.eCashBlue}; `; const GenesisTx = styled(TxIcon)` border-color: ${props => props.theme.genesisGreen}; svg { fill: ${props => props.theme.genesisGreen}; } `; const UnparsedTx = styled(TxIcon)` color: ${props => props.theme.eCashBlue} !important; `; const DateType = styled.div` text-align: left; padding: 12px; @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const GenesisHeader = styled.h3``; const ReceivedHeader = styled.h3``; const LeftTextCtn = styled.div` text-align: left; display: flex; align-items: left; flex-direction: column; margin-left: 10px; h3 { color: ${props => props.theme.contrast}; font-size: 14px; font-weight: 700; margin: 0; } ${GenesisHeader} { color: ${props => props.theme.genesisGreen}; } ${ReceivedHeader} { color: ${props => props.theme.eCashBlue}; } h4 { font-size: 12px; color: ${props => props.theme.lightWhite}; margin: 0; } `; const RightTextCtn = styled.div` text-align: right; display: flex; align-items: left; flex-direction: column; margin-left: 10px; h3 { color: ${props => props.theme.contrast}; font-size: 14px; font-weight: 700; margin: 0; } h4 { font-size: 12px; color: ${props => props.theme.lightWhite}; margin: 0; } `; const OpReturnType = styled.div` text-align: right; width: 100%; padding: 10px; border-radius: 5px; background: ${props => props.theme.sentMessage}; margin-top: 15px; transition: max-height 500ms cubic-bezier(0, 1, 0, 1); ${({ hideMessagesFromUnknownSenders }) => hideMessagesFromUnknownSenders && ` max-height: auto; &[aria-expanded='true'] { max-height: 5000px; transition: max-height 500ms ease-in; } &[aria-expanded='false'] { transition: max-height 200ms ease-in; max-height: 6rem; } `} h4 { color: ${props => props.theme.lightWhite}; margin: 0; font-size: 12px; display: inline-block; } p { color: ${props => props.theme.contrast}; margin: 0; font-size: 14px; margin-bottom: 10px; overflow-wrap: break-word; } a { color: ${props => props.theme.contrast}; margin: 0px 0px 0px 5px; font-size: 10px; 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 ShowHideMessageButton = styled.button` color: ${props => props.theme.contrast}; background-color: transparent; margin: 0px 0px 0px 5px; font-size: 10px; border: 1px solid ${props => props.theme.contrast}; border-radius: 5px; padding: 1.6px 10px; opacity: 0.6; &:hover { opacity: 1; border-color: ${props => props.theme.eCashBlue}; color: ${props => props.theme.contrast}; background: ${props => props.theme.eCashBlue}; } `; const ReceivedLabel = styled.span` font-weight: bold; color: ${props => props.theme.eCashBlue} !important; `; const EncryptionMessageLabel = styled.span` font-weight: bold; font-size: 12px; color: ${props => props.theme.encryptionRed}; white-space: nowrap; `; const UnauthorizedDecryptionMessage = styled.span` text-align: left; color: ${props => props.theme.encryptionRed}; white-space: nowrap; font-style: italic; `; const DecryptedMessage = styled.div` ${props => props.authorized ? { color: `${props => props.theme.contrast}`, margin: '0', fontSize: '14px', marginBottom: '10px', overflowWrap: 'break-word', } : `${UnauthorizedDecryptionMessage}`} `; const TxInfo = styled.div` text-align: right; display: flex; align-items: left; flex-direction: column; margin-left: 10px; flex-grow: 2; h3 { color: ${props => props.theme.contrast}; font-size: 14px; font-weight: 700; margin: 0; } h4 { font-size: 12px; color: ${props => props.theme.lightWhite}; margin: 0; } @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const TokenInfo = styled.div` display: flex; flex-grow: 1; justify-content: flex-end; color: ${props => props.outgoing ? props.theme.secondary : props.theme.eCashBlue}; @media screen and (max-width: 500px) { font-size: 0.8rem; grid-template-columns: 16px auto; } `; const TxTokenIcon = styled.div` img { height: 24px; width: 24px; } @media screen and (max-width: 500px) { img { height: 16px; width: 16px; } } grid-column-start: 1; grid-column-end: span 1; grid-row-start: 1; grid-row-end: span 2; align-self: center; `; const TokenTxAmt = styled.h3` text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TokenTxAmtGenesis = styled(TokenTxAmt)` color: ${props => props.theme.genesisGreen} !important; `; const TokenTxAmtReceived = styled(TokenTxAmt)` color: ${props => props.theme.eCashBlue} !important; `; const TokenName = styled.h4` text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TxWrapper = styled.div` display: flex; align-items: center; border-top: 1px solid rgba(255, 255, 255, 0.12); color: ${props => props.theme.contrast}; padding: 10px 0; flex-wrap: wrap; width: 100%; `; const AntdContextCollapseWrapper = styled.div` .ant-collapse { border: none !important; background-color: transparent !important; } .ant-collapse-item { border: none !important; } .ant-collapse-header { padding: 0 !important; color: ${props => props.theme.forms.text} !important; } border-radius: 16px; .ant-collapse-content-box { padding-right: 0 !important; } @media screen and (max-width: 500px) { grid-template-columns: 24px 30% 50%; } `; const Panel = Collapse.Panel; const DropdownIconWrapper = styled.div` display: flex; align-items: center; gap: 4px; `; const TextLayer = styled.div` font-size: 12px; color: ${props => props.theme.contrast}; white-space: nowrap; `; const DropdownButton = styled.button` display: flex; justify-content: flex-end; position: relative; background-color: ${props => props.theme.walletBackground}; border: none; cursor: pointer; padding: 0; &:hover { div { color: ${props => props.theme.eCashBlue}!important; } svg { fill: ${props => props.theme.eCashBlue}!important; } } `; const PanelCtn = styled.div` display: flex; align-items: center; justify-content: flex-end; right: 0; gap: 8px; @media (max-width: 500px) { flex-wrap: wrap; } `; const TxLink = styled.a` color: ${props => props.theme.primary}; `; const NotInContactsAlert = styled.h4` color: ${props => props.theme.forms.error} !important; font-style: italic; `; const ReceivedFromCtn = styled.div` display: flex; align-items: center; justify-content: center; gap: 3px; h4 { margin-top: 2.5px; } `; const Tx = ({ data, fiatPrice, fiatCurrency, addressesInContactList, contactList, cashtabSettings, }) => { const [displayedMessage, setDisplayedMessage] = useState(false); const handleShowMessage = () => { setDisplayedMessage(!displayedMessage); }; const txDate = formatDate(data.timeFirstSeen, navigator.language); // A wallet migrating from bch-api tx history to chronik will get caught here for one update cycle let unparsedTx = false; if (!Object.keys(data).includes('parsed')) { unparsedTx = true; } return ( <> {unparsedTx ? ( Unparsed
{txDate}
Open in Explorer
) : ( {!data.parsed.incoming ? ( <> {data.parsed.isEtokenTx && data.slpTxData.slpMeta .txType === 'GENESIS' ? ( ) : data.parsed.isTokenBurn ? ( ) : ( )} ) : ( )} {!data.parsed.incoming ? ( <> {data.parsed.isEtokenTx && data.slpTxData.slpMeta .txType === 'GENESIS' ? ( Genesis ) : (

{data.parsed .isTokenBurn ? 'Burned' : 'Sent'}

)} ) : ( Received {addressesInContactList.includes( - data.parsed.legacy + data.parsed .replyAddress, ) && ( <>

from

{contactList.map( ( contact, index, ) => { let result; const contactAddress = contact.address; const dataAddress = data .parsed - .legacy .replyAddress; if ( contactAddress === dataAddress ) { result = contact.name; } else { result = ''; } return (

{ result }

); }, )} )}
)}

{txDate}

{data.parsed.isEtokenTx ? ( {data.parsed.isEtokenTx && data.parsed.genesisInfo ? ( <> {!data.parsed .incoming ? ( {data.slpTxData .slpMeta .txType === 'GENESIS' ? ( <> +{' '} {data.parsed.etokenAmount.toString()}   { data .parsed .genesisInfo .tokenTicker } { data .parsed .genesisInfo .tokenName } ) : ( <> -{' '} {data.parsed.etokenAmount.toString()}   { data .parsed .genesisInfo .tokenTicker } { data .parsed .genesisInfo .tokenName } )} ) : ( +{' '} {data.parsed.etokenAmount.toString()}   { data .parsed .genesisInfo .tokenTicker } { data .parsed .genesisInfo .tokenName } )} ) : ( Token Tx )} ) : ( <> {!data.parsed.incoming ? ( <>

- {formatBalance( data.parsed .xecAmount, )}{' '} { currency.ticker }

{fiatPrice !== null && !isNaN( data.parsed .xecAmount, ) && (

- { currency .fiatCurrencies[ fiatCurrency ] .symbol } {( data .parsed .xecAmount * fiatPrice ).toFixed( 2, )}{' '} { currency .fiatCurrencies .fiatCurrency }

)} ) : ( <> + {formatBalance( data.parsed .xecAmount, )}{' '} { currency.ticker } {fiatPrice !== null && !isNaN( data.parsed .xecAmount, ) && (

+ { currency .fiatCurrencies[ fiatCurrency ] .symbol } {( data .parsed .xecAmount * fiatPrice ).toFixed( 2, )}{' '} { currency .fiatCurrencies .fiatCurrency }

)} )}
)} - {data.parsed.legacy.opReturnMessage && ( + {data.parsed.opReturnMessage && ( <> {data.parsed.incoming && !addressesInContactList.includes( - data.parsed.legacy + data.parsed .replyAddress, ) && ( Warning: This sender is not in your contact list. Beware of scams. )} - {data.parsed.legacy + {data.parsed .isCashtabMessage ? (

Cashtab Message{' '}

) : (

External Message

)} - {data.parsed.legacy + {data.parsed .isEncryptedMessage ? (  - Encrypted ) : ( '' )}
{cashtabSettings.hideMessagesFromUnknownSenders ? ( <> {/*unencrypted OP_RETURN Message*/} - {data.parsed.legacy + {data.parsed .opReturnMessage && !data.parsed - .legacy .isEncryptedMessage && ( <> {!displayedMessage && data .parsed .incoming && !addressesInContactList.includes( data .parsed - .legacy .replyAddress, ) ? ( { e.stopPropagation(); handleShowMessage(); }} > Show ) : ( <>

{' '} { data .parsed - .legacy .opReturnMessage }

{!addressesInContactList.includes( data .parsed - .legacy .replyAddress, ) && data .parsed .incoming && ( { e.stopPropagation(); handleShowMessage(); }} > Hide )} )} )} - {data.parsed.legacy + {data.parsed .opReturnMessage && data.parsed - .legacy .isEncryptedMessage && ( <> {!displayedMessage && data .parsed .incoming && !addressesInContactList.includes( data .parsed - .legacy .replyAddress, ) ? ( { e.stopPropagation(); handleShowMessage(); }} > Show ) : ( <> { data .parsed - .legacy .opReturnMessage } {!addressesInContactList.includes( data .parsed - .legacy .replyAddress, ) && // do not render 'Hide' button if msg cannot be decrypted data .parsed - .legacy .decryptionSuccess && ( { e.stopPropagation(); handleShowMessage(); }} > Hide )} )} )} ) : ( <> {/*unencrypted OP_RETURN Message*/} - {data.parsed.legacy + {data.parsed .opReturnMessage && - !data.parsed.legacy + !data.parsed .isEncryptedMessage ? (

{ data .parsed - .legacy .opReturnMessage }

) : ( '' )} {/*encrypted and wallet is authorized to view OP_RETURN Message*/} - {data.parsed.legacy + {data.parsed .opReturnMessage && - data.parsed.legacy + data.parsed .isEncryptedMessage && - data.parsed.legacy + data.parsed .decryptionSuccess ? (

{ data .parsed - .legacy .opReturnMessage }

) : ( '' )} {/*encrypted but wallet is not authorized to view OP_RETURN Message*/} - {data.parsed.legacy + {data.parsed .opReturnMessage && - data.parsed.legacy + data.parsed .isEncryptedMessage && - !data.parsed.legacy + !data.parsed .decryptionSuccess ? ( { data .parsed - .legacy .opReturnMessage } ) : ( '' )} )} {(data.parsed.incoming && - data.parsed.legacy + data.parsed .replyAddress && addressesInContactList.includes( - data.parsed.legacy + data.parsed .replyAddress, )) || (!cashtabSettings.hideMessagesFromUnknownSenders && data.parsed.incoming && - data.parsed.legacy + data.parsed .replyAddress && displayedMessage) ? ( Reply ) : ( '' )}
)}
} > Txid - {data.parsed.legacy.opReturnMessage && ( + {data.parsed.opReturnMessage && ( Msg )} View on e.cash Receipt {!!data.parsed.incoming && - data.parsed.legacy.replyAddress && + data.parsed.replyAddress && !addressesInContactList.includes( - data.parsed.legacy.replyAddress, + data.parsed.replyAddress, ) && ( Add to contacts )}
)} ); }; Tx.propTypes = { data: PropTypes.object, fiatPrice: PropTypes.number, fiatCurrency: PropTypes.string, addressesInContactList: PropTypes.arrayOf(PropTypes.string), contactList: PropTypes.arrayOf( PropTypes.shape({ address: PropTypes.string, name: PropTypes.string, }), ), cashtabSettings: PropTypes.oneOfType([ PropTypes.shape({ fiatCurrency: PropTypes.string, sendModal: PropTypes.bool, autoCameraOn: PropTypes.bool, hideMessagesFromUnknownSenders: PropTypes.bool, }), PropTypes.bool, ]), }; export default Tx; diff --git a/web/cashtab/src/utils/__tests__/chronik.test.js b/web/cashtab/src/utils/__tests__/chronik.test.js index 093d7955a..0ab8008d3 100644 --- a/web/cashtab/src/utils/__tests__/chronik.test.js +++ b/web/cashtab/src/utils/__tests__/chronik.test.js @@ -1,705 +1,685 @@ import BigNumber from 'bignumber.js'; import { currency } from '../../components/Common/Ticker'; import { organizeUtxosByType, getPreliminaryTokensArray, finalizeTokensArray, finalizeSlpUtxos, getTokenStats, flattenChronikTxHistory, sortAndTrimChronikTxHistory, parseChronikTx, } 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, } from '../__mocks__/chronikTxHistory'; 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, - legacy: { - airdropFlag: false, - airdropTokenId: '', + airdropFlag: false, + airdropTokenId: '', - decryptionSuccess: false, - isCashtabMessage: false, - isEncryptedMessage: false, - opReturnMessage: '', - replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', - }, + 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, - legacy: { - airdropFlag: false, - airdropTokenId: '', + airdropFlag: false, + airdropTokenId: '', - decryptionSuccess: false, - isCashtabMessage: false, - isEncryptedMessage: false, - opReturnMessage: '', - replyAddress: 'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj', - }, + 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', - legacy: { - airdropFlag: false, - airdropTokenId: '', + airdropFlag: false, + airdropTokenId: '', - decryptionSuccess: false, - isCashtabMessage: false, - isEncryptedMessage: false, - opReturnMessage: '', - replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', - }, + 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', - legacy: { - airdropFlag: false, - airdropTokenId: '', + airdropFlag: false, + airdropTokenId: '', - decryptionSuccess: false, - isCashtabMessage: false, - isEncryptedMessage: false, - opReturnMessage: '', - replyAddress: 'ecash:qpmytrdsakt0axrrlswvaj069nat3p9s7cjctmjasj', - }, + 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', }, - legacy: { - airdropFlag: false, - airdropTokenId: '', - opReturnMessage: '', - isCashtabMessage: false, - isEncryptedMessage: false, - decryptionSuccess: false, - replyAddress: 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035', - }, + 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', }, - legacy: { - airdropFlag: false, - airdropTokenId: '', - opReturnMessage: '', - isCashtabMessage: false, - isEncryptedMessage: false, - decryptionSuccess: false, - replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', - }, + + 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, - legacy: { - airdropFlag: true, - airdropTokenId: - 'bdb3b4215ca0622e0c4c07655522c376eaa891838a82f0217fa453bb0595a37c', - opReturnMessage: - 'evc token service holders air dropπŸ₯‡πŸŒπŸ₯‡β€πŸ‘ŒπŸ›¬πŸ›¬πŸ—πŸ€΄', - isCashtabMessage: true, - isEncryptedMessage: false, - decryptionSuccess: false, - replyAddress: 'ecash:qp36z7k8xt7k4l5xnxeypg5mfqeyvvyduu04m37fwd', - }, + + 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, - legacy: { - airdropFlag: false, - airdropTokenId: '', - opReturnMessage: 'Only the message recipient can view this', - isCashtabMessage: true, - isEncryptedMessage: true, - decryptionSuccess: false, - replyAddress: 'ecash:qrhxmjw5p72a3cgx5cect3h63q5erw0gfcvjnyv7xt', - }, + + 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, - legacy: { - airdropFlag: false, - airdropTokenId: '', - opReturnMessage: 'Test encrypted message', - isCashtabMessage: true, - isEncryptedMessage: true, - decryptionSuccess: true, - replyAddress: 'ecash:qp89xgjhcqdnzzemts0aj378nfe2mhu9yvxj9nhgg6', - }, + 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, }, - legacy: { - airdropFlag: false, - airdropTokenId: '', - opReturnMessage: '', - isCashtabMessage: false, - isEncryptedMessage: false, - decryptionSuccess: false, - replyAddress: 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035', - }, + 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, }, - legacy: { - airdropFlag: false, - airdropTokenId: '', - opReturnMessage: '', - isCashtabMessage: false, - isEncryptedMessage: false, - decryptionSuccess: false, - replyAddress: 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035', - }, + airdropFlag: false, + airdropTokenId: '', + opReturnMessage: '', + isCashtabMessage: false, + isEncryptedMessage: false, + decryptionSuccess: false, + replyAddress: 'ecash:qz2708636snqhsxu8wnlka78h6fdp77ar59jrf5035', }); }); diff --git a/web/cashtab/src/utils/chronik.js b/web/cashtab/src/utils/chronik.js index 90cb1e1aa..7efab015a 100644 --- a/web/cashtab/src/utils/chronik.js +++ b/web/cashtab/src/utils/chronik.js @@ -1,831 +1,827 @@ // Chronik methods import BigNumber from 'bignumber.js'; import { currency } from 'components/Common/Ticker'; import { parseOpReturn, convertToEncryptStruct, getHashArrayFromWallet, getUtxoWif, } 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); } } console.log(`confirmed txs`, confirmedTxs); console.log(`unconfirmed txs`, unconfirmedTxs); // 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 => { console.log( `result for ${hash160AndAddressObj.hash160}`, 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; const isEtokenTx = 'slpTxData' in tx && typeof tx.slpTxData !== 'undefined'; const isGenesisTx = isEtokenTx && tx.slpTxData.slpMeta && tx.slpTxData.slpMeta.txType && tx.slpTxData.slpMeta.txType === 'GENESIS'; if (isGenesisTx) { console.log(`${tx.txid} isGenesisTx`); } - // Defining variables used in lines legacy parseTxData function from useBCH.js + // 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') { console.log(`Token burn at ${tx.txid}`); // 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); console.log(`replyAddressXecFormat`, replyAddress); } 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 ) { // 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) { throw new Error('Private key extraction 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 = 'Only the message recipient can view this'; } 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, 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 } } } } // 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]; genesisInfo.success = true; // tokenGenesisInfo should be there for every tx in tx history, since it's already been cached for every utxo in the wallet // but try...catch just in case decimals = genesisInfo.decimals; etokenAmount = etokenAmount.shiftedBy(-1 * decimals); } 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(); if (isTokenBurn) { console.log(`${etokenAmount} of ${genesisInfo.tokenName} burned`); } // 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, - legacy: { - airdropFlag, - airdropTokenId, - opReturnMessage: '', - isCashtabMessage, - isEncryptedMessage, - decryptionSuccess, - replyAddress, - }, + airdropFlag, + airdropTokenId, + opReturnMessage: '', + isCashtabMessage, + isEncryptedMessage, + decryptionSuccess, + replyAddress, }; } // Otherwise do not include these fields return { incoming, xecAmount, originatingHash160, isEtokenTx, - legacy: { - airdropFlag, - airdropTokenId, - opReturnMessage, - isCashtabMessage, - isEncryptedMessage, - decryptionSuccess, - replyAddress, - }, + 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 console.log(`tokenInfoById`, tokenInfoById); 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); } console.log(`txHistoryOfAllAddresses`, txHistoryOfAllAddresses); const flatTxHistoryArray = flattenChronikTxHistory(txHistoryOfAllAddresses); console.log(`flatTxHistoryArray`, flatTxHistoryArray); const sortedTxHistoryArray = sortAndTrimChronikTxHistory( flatTxHistoryArray, currency.txHistoryCount, ); // Parse txs const parsedTxs = []; 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); parsedTxs.push(sortedTx); } return parsedTxs; };