diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -46,6 +46,7 @@ isValidEtokenAddress, isValidEtokenBurnAmount, } from 'utils/validation'; +import { getTokenStats } from 'utils/chronik'; import { formatDate } from 'utils/formatting'; import styled, { css } from 'styled-components'; import TokenIcon from 'components/Tokens/TokenIcon'; @@ -93,7 +94,7 @@ `; const SendToken = ({ tokenId, jestBCH, passLoadingStatus }) => { - const { BCH, wallet, apiError, cashtabSettings } = + const { BCH, wallet, apiError, cashtabSettings, chronik } = React.useContext(WalletContext); const walletState = getWalletState(wallet); const { tokens } = walletState; @@ -130,7 +131,7 @@ address: '', }); - const { getRestUrl, sendToken, getTokenStats, burnToken } = useBCH(); + const { getRestUrl, sendToken, burnToken } = useBCH(); useEffect(() => { // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); @@ -142,7 +143,7 @@ // Fetch token stats if you do not have them and API did not return an error if (tokenStats === null) { - getTokenStats(bchObj, tokenId).then( + getTokenStats(chronik, tokenId).then( result => { setTokenStats(result); }, @@ -650,13 +651,19 @@ {tokenStats && ( <> - {tokenStats.documentUri} + { + tokenStats.slpTxData + .genesisInfo + .tokenDocumentUrl + } - {tokenStats.timestampUnix !== - null + {tokenStats.block && + tokenStats.block + .timestamp !== null ? formatDate( - tokenStats.timestampUnix, + tokenStats.block + .timestamp, navigator.language, ) : 'Just now (Genesis tx confirming)'} @@ -667,16 +674,40 @@ : 'Yes'} - {tokenStats.initialTokenQty.toLocaleString()} + {new BigNumber( + tokenStats.initialTokenQuantity, + ) + .toFormat( + token.info.decimals, + ) + .toLocaleString()} - {tokenStats.totalBurned.toLocaleString()} + {new BigNumber( + tokenStats.tokenStats.totalBurned, + ) + .toFormat( + token.info.decimals, + ) + .toLocaleString()} - {tokenStats.totalMinted.toLocaleString()} + {new BigNumber( + tokenStats.tokenStats.totalMinted, + ) + .toFormat( + token.info.decimals, + ) + .toLocaleString()} - {tokenStats.circulatingSupply.toLocaleString()} + {new BigNumber( + tokenStats.circulatingSupply, + ) + .toFormat( + token.info.decimals, + ) + .toLocaleString()} { - let tokenStats; - try { - tokenStats = await BCH.SLP.Utils.tokenStats(tokenId); - if (isValidTokenStats(tokenStats)) { - return tokenStats; - } - } catch (err) { - console.log(`Error fetching token stats for tokenId ${tokenId}`); - console.log(err); - return false; - } - }; - const sendToken = async ( BCH, wallet, @@ -1418,7 +1401,6 @@ sendXec, sendToken, createToken, - getTokenStats, handleEncryptedOpReturn, getRecipientPublicKey, burnToken, diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -1399,6 +1399,7 @@ return { BCH, + chronik, wallet, fiatPrice, loading, diff --git a/web/cashtab/src/utils/__mocks__/mockChronikTokenStats.js b/web/cashtab/src/utils/__mocks__/mockChronikTokenStats.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/utils/__mocks__/mockChronikTokenStats.js @@ -0,0 +1,64 @@ +import BigNumber from 'bignumber.js'; + +export const mockChronikTokenResponse = { + slpTxData: { + slpMeta: { + tokenType: 'FUNGIBLE', + txType: 'GENESIS', + tokenId: + '3c14fcdc3fce9738d213c1ab9d9ff18234fecab9d1ad5a77d3f7b95964269f4a', + }, + genesisInfo: { + tokenTicker: 'VVS', + tokenName: 'ethantest', + tokenDocumentUrl: 'https://cashtab.com/', + tokenDocumentHash: '', + decimals: 3, + }, + }, + tokenStats: { + totalMinted: '21000000000', + totalBurned: '3056', + }, + block: { + height: 758409, + hash: '00000000000000000f305eafc05bffd14de4acf52787596b5927199c9cab37da', + timestamp: '1663859004', + }, + timeFirstSeen: '1663858438', + initialTokenQuantity: '21000000000', + containsBaton: false, + network: 'XEC', +}; + +export const mockGetTokenStatsReturn = { + slpTxData: { + slpMeta: { + tokenType: 'FUNGIBLE', + txType: 'GENESIS', + tokenId: + '3c14fcdc3fce9738d213c1ab9d9ff18234fecab9d1ad5a77d3f7b95964269f4a', + }, + genesisInfo: { + tokenTicker: 'VVS', + tokenName: 'ethantest', + tokenDocumentUrl: 'https://cashtab.com/', + tokenDocumentHash: '', + decimals: 3, + }, + }, + tokenStats: { + totalMinted: '21000000', + totalBurned: '3.056', + }, + block: { + height: 758409, + hash: '00000000000000000f305eafc05bffd14de4acf52787596b5927199c9cab37da', + timestamp: '1663859004', + }, + timeFirstSeen: '1663858438', + initialTokenQuantity: '21000000', + containsBaton: false, + network: 'XEC', + circulatingSupply: '20999996.944', +}; diff --git a/web/cashtab/src/utils/__tests__/chronik.test.js b/web/cashtab/src/utils/__tests__/chronik.test.js --- a/web/cashtab/src/utils/__tests__/chronik.test.js +++ b/web/cashtab/src/utils/__tests__/chronik.test.js @@ -1,8 +1,10 @@ +import BigNumber from 'bignumber.js'; import { organizeUtxosByType, getPreliminaryTokensArray, finalizeTokensArray, finalizeSlpUtxos, + getTokenStats, } from 'utils/chronik'; import { mockChronikUtxos, @@ -19,9 +21,33 @@ mockFinalizedSlpUtxos, mockTokenInfoById, } from '../__mocks__/chronikUtxos'; +import { + mockChronikTokenResponse, + mockGetTokenStatsReturn, +} from '../__mocks__/mockChronikTokenStats'; import { ChronikClient } from 'chronik-client'; import { when } from 'jest-when'; +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, diff --git a/web/cashtab/src/utils/__tests__/validation.test.js b/web/cashtab/src/utils/__tests__/validation.test.js --- a/web/cashtab/src/utils/__tests__/validation.test.js +++ b/web/cashtab/src/utils/__tests__/validation.test.js @@ -6,7 +6,6 @@ isValidTokenDecimals, isValidTokenInitialQty, isValidTokenDocumentUrl, - isValidTokenStats, isValidCashtabSettings, isValidXecAddress, isValidNewWalletNameLength, @@ -250,18 +249,6 @@ it(`Rejects a domain input as numbers ${currency.tokenTicker} token document URL`, () => { expect(isValidTokenDocumentUrl(12345)).toBe(false); }); - it(`Correctly validates token stats for token created before the ${currency.ticker} fork`, () => { - expect(isValidTokenStats(stStatsValid)).toBe(true); - }); - it(`Correctly validates token stats for token created after the ${currency.ticker} fork`, () => { - expect(isValidTokenStats(noCovidStatsValid)).toBe(true); - }); - it(`Correctly validates token stats for token with no minting baton`, () => { - expect(isValidTokenStats(cGenStatsValid)).toBe(true); - }); - it(`Recognizes a token stats object with missing required keys as invalid`, () => { - expect(isValidTokenStats(noCovidStatsInvalid)).toBe(false); - }); it(`Recognizes the default cashtabCache object as valid`, () => { expect(isValidCashtabCache(currency.defaultCashtabCache)).toBe(true); }); diff --git a/web/cashtab/src/utils/chronik.js b/web/cashtab/src/utils/chronik.js --- a/web/cashtab/src/utils/chronik.js +++ b/web/cashtab/src/utils/chronik.js @@ -1,5 +1,49 @@ // Chronik methods import BigNumber from 'bignumber.js'; + +// 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 diff --git a/web/cashtab/src/utils/validation.js b/web/cashtab/src/utils/validation.js --- a/web/cashtab/src/utils/validation.js +++ b/web/cashtab/src/utils/validation.js @@ -105,19 +105,6 @@ ); }; -export const isValidTokenStats = tokenStats => { - return ( - typeof tokenStats === 'object' && - 'timestampUnix' in tokenStats && - 'documentUri' in tokenStats && - 'containsBaton' in tokenStats && - 'initialTokenQty' in tokenStats && - 'totalMinted' in tokenStats && - 'totalBurned' in tokenStats && - 'circulatingSupply' in tokenStats - ); -}; - export const isValidCashtabSettings = settings => { try { let isValidSettingParams = true;