diff --git a/cashtab/src/components/Agora/OrderBook.js b/cashtab/src/components/Agora/OrderBook.js --- a/cashtab/src/components/Agora/OrderBook.js +++ b/cashtab/src/components/Agora/OrderBook.js @@ -57,7 +57,6 @@ OrderbookPrice, SliderRow, SliderInfoRow, - ChronikErrorAlert, } from './styled'; import PrimaryButton, { SecondaryButton, @@ -72,6 +71,7 @@ import TokenIcon from 'components/Etokens/TokenIcon'; import { getAgoraPartialAcceptTokenQtyError } from 'validation'; import { QuestionIcon } from 'components/Common/CustomIcons'; +import { ChronikErrorAlert } from 'components/Common/Atoms'; const OrderBook = ({ tokenId, diff --git a/cashtab/src/components/Agora/index.js b/cashtab/src/components/Agora/index.js --- a/cashtab/src/components/Agora/index.js +++ b/cashtab/src/components/Agora/index.js @@ -4,16 +4,11 @@ import React, { useState, useEffect } from 'react'; import { WalletContext } from 'wallet/context'; -import { SwitchLabel } from 'components/Common/Atoms'; +import { SwitchLabel, ChronikErrorAlert } from 'components/Common/Atoms'; import Spinner from 'components/Common/Spinner'; import { getTokenGenesisInfo } from 'chronik'; import { toHex } from 'ecash-lib'; -import { - ActiveOffers, - OfferTitle, - OfferTable, - ChronikErrorAlert, -} from './styled'; +import { ActiveOffers, OfferTitle, OfferTable } from './styled'; import { SwitchHolder } from 'components/Etokens/Token/styled'; import { getUserLocale } from 'helpers'; import * as wif from 'wif'; diff --git a/cashtab/src/components/Agora/styled.js b/cashtab/src/components/Agora/styled.js --- a/cashtab/src/components/Agora/styled.js +++ b/cashtab/src/components/Agora/styled.js @@ -4,7 +4,7 @@ import styled from 'styled-components'; import { token as tokenConfig } from 'config/token'; -import { Alert, CashtabScroll } from 'components/Common/Atoms'; +import { CashtabScroll } from 'components/Common/Atoms'; export const ActiveOffers = styled.div` color: ${props => props.theme.contrast}; @@ -51,6 +51,7 @@ `; export const OfferRow = styled.div` word-break: break-word; + color: ${props => props.theme.contrast}; display: flex; flex-direction: row; flex-wrap: wrap; @@ -61,6 +62,7 @@ `; export const SliderRow = styled.div` word-break: break-word; + color: ${props => props.theme.contrast}; display: flex; flex-direction: row; flex-wrap: wrap; @@ -101,9 +103,7 @@ justify-content: center; gap: 3px; `; -export const ChronikErrorAlert = styled(Alert)` - margin-top: 12px; -`; + export const DepthBar = styled.div` display: flex; flex-direction: row; diff --git a/cashtab/src/components/Common/Atoms.js b/cashtab/src/components/Common/Atoms.js --- a/cashtab/src/components/Common/Atoms.js +++ b/cashtab/src/components/Common/Atoms.js @@ -86,12 +86,14 @@ border-radius: 12px; color: red; padding: 12px; + margin: 12px 0; `; export const Info = styled.div` background-color: #fff2f0; border-radius: 12px; color: ${props => props.theme.eCashBlue}; padding: 12px; + margin: 12px 0; `; export const BlockNotification = styled.div` display: flex; @@ -109,3 +111,14 @@ justify-content: flex-start; width: 100%; `; + +export const ChronikErrorAlert = styled(Alert)` + margin-top: 12px; +`; +export const SubHeading = styled.div` + margin: 12px 0; + color: ${props => props.theme.contrast}; + font-size: 20px; + text-align: center; + font-weight: bold; +`; diff --git a/cashtab/src/components/Etokens/Token/index.js b/cashtab/src/components/Etokens/Token/index.js --- a/cashtab/src/components/Etokens/Token/index.js +++ b/cashtab/src/components/Etokens/Token/index.js @@ -10,7 +10,15 @@ IconButton, CopyIconButton, } from 'components/Common/Buttons'; -import { TxLink, SwitchLabel } from 'components/Common/Atoms'; +import { + LoadingCtn, + TxLink, + SwitchLabel, + ChronikErrorAlert, + Info, + Alert, + SubHeading, +} from 'components/Common/Atoms'; import BalanceHeaderToken from 'components/Common/BalanceHeaderToken'; import { useNavigate } from 'react-router-dom'; import { Event } from 'components/Common/GoogleAnalytics'; @@ -29,6 +37,7 @@ import TokenIcon from 'components/Etokens/TokenIcon'; import { explorer } from 'config/explorer'; import { queryAliasServer } from 'alias'; +import { token as tokenConfig } from 'config/token'; import aliasSettings from 'config/alias'; import cashaddr from 'ecashaddrjs'; import appConfig from 'config/app'; @@ -100,6 +109,7 @@ AgoraPreviewRow, AgoraPreviewLabel, AgoraPreviewCol, + TokenScreenWrapper, } from 'components/Etokens/Token/styled'; import CreateTokenForm from 'components/Etokens/CreateTokenForm'; import { @@ -125,6 +135,8 @@ AgoraPartialAdSignatory, } from 'ecash-agora'; import * as wif from 'wif'; +import Spinner from 'components/Common/Spinner'; +import OrderBook from 'components/Agora/OrderBook'; const Token = () => { let navigate = useNavigate(); @@ -141,6 +153,13 @@ } = useContext(WalletContext); const { settings, wallets, cashtabCache } = cashtabState; const wallet = wallets.length > 0 ? wallets[0] : false; + // We get public key when wallet changes + const sk = + wallet === false + ? false + : wif.decode(wallet.paths.get(appConfig.derivationPath).wif) + .privateKey; + const pk = sk === false ? false : ecc.derivePubkey(sk); const walletState = getWalletState(wallet); const { tokens, balanceSats } = walletState; @@ -221,6 +240,10 @@ } } + const [chronikQueryError, setChronikQueryError] = useState(false); + const [agoraQueryError, setAgoraQueryError] = useState(false); + // We reload offers when this changes state. Buying or canceling an offer toggles it. + const [hasActiveOffers, setHasActiveOffers] = useState(null); const [nftTokenIds, setNftTokenIds] = useState([]); const [nftChildGenesisInput, setNftChildGenesisInput] = useState([]); const [nftFanInputs, setNftFanInputs] = useState([]); @@ -425,21 +448,18 @@ } }; - const updateNftCachedInfo = async tokenId => { - const cachedInfoWithGroupTokenId = await getTokenGenesisInfo( - chronik, - tokenId, - ); - cashtabCache.tokens.set(tokenId, cachedInfoWithGroupTokenId); - updateCashtabState('cashtabCache', cashtabCache); - }; - const addNftCollectionToCache = async nftParentTokenId => { - const nftParentCachedInfo = await getTokenGenesisInfo( - chronik, - nftParentTokenId, - ); - cashtabCache.tokens.set(nftParentTokenId, nftParentCachedInfo); - updateCashtabState('cashtabCache', cashtabCache); + const addTokenToCashtabCache = async tokenId => { + try { + const cachedInfoWithGroupTokenId = await getTokenGenesisInfo( + chronik, + tokenId, + ); + cashtabCache.tokens.set(tokenId, cachedInfoWithGroupTokenId); + updateCashtabState('cashtabCache', cashtabCache); + } catch (err) { + console.error(`Error getting token details for ${tokenId}`, err); + setChronikQueryError(true); + } }; useEffect(() => { @@ -448,7 +468,7 @@ if (typeof cachedInfo.groupTokenId === 'undefined') { // If this is an NFT and its groupTokenId is not cached // Update this tokens cached info - updateNftCachedInfo(tokenId); + addTokenToCashtabCache(tokenId); } else { // If we do have a groupTokenId, check if we have cached token info about the group const nftCollectionCachedInfo = cashtabCache.tokens.get( @@ -456,7 +476,7 @@ ); if (typeof nftCollectionCachedInfo === 'undefined') { // If we do not have the NFT collection token info in cache, add it - addNftCollectionToCache(cachedInfo.groupTokenId); + addTokenToCashtabCache(cachedInfo.groupTokenId); } } } @@ -464,30 +484,12 @@ useEffect(() => { if (typeof cashtabCache.tokens.get(tokenId) === 'undefined') { - // Wait for token info to be available from cache - // For now, this is only relevant to unit tests - // But, in the future, we will want to support a user visiting a token page - // of a token he does not have - return; + // If we do not have this token's info, get it + addTokenToCashtabCache(tokenId); } // Get token info that is not practical to cache as it is subject to change getUncachedTokenInfo(); - }, [cashtabCache.tokens.get(tokenId)]); - - useEffect(() => { - if ( - loading === false && - (typeof tokenBalance === 'undefined' || - typeof cashtabCache.tokens.get(tokenId) === 'undefined') - ) { - // token can be undefined when the app is loading - // in practice, this only happens in integration tests or when the user navigates directly - // to send/tokenId screen, as cashtab locks UI while it loads - // token becomes undefined when a user sends or burns all of their balance for this token - // In this case -- loading === true and token === undefined -- navigate to the home page - navigate('/'); - } - }, [loading, tokenBalance, cashtabCache]); + }, [tokenId, cashtabCache.tokens.get(tokenId)]); useEffect(() => { if (!isSupportedToken) { @@ -501,12 +503,19 @@ if (isNftChild) { // Default action is list setSwitches({ ...switchesOff, showSellNft: true }); + // TODO see if there are ONESHOT offers for this NFT + // TODO see trading history of this NFT } else if (tokenType.type === 'SLP_TOKEN_TYPE_FUNGIBLE') { setSwitches({ ...switchesOff, showSellSlp: true }); + // Check for active offers for this SLP1 token + checkForActiveOffers(); + // TODO see trading history of this token } else { // Default action is send setSwitches({ ...switchesOff, showSend: true }); } + } else { + // todo look for ONESHOT offers in this collection } }, [isSupportedToken, isNftParent, isNftChild]); @@ -522,6 +531,22 @@ } }, [slpAgoraPartialTokenQty]); + const checkForActiveOffers = async () => { + try { + const activeOffersThisTokenId = await agora.activeOffersByTokenId( + tokenId, + ); + + setHasActiveOffers(activeOffersThisTokenId.length > 0); + } catch (err) { + console.error( + `Error getting agora.activeOffersByTokenId(${tokenId})`, + err, + ); + return setAgoraQueryError(true); + } + }; + const getNfts = async tokenId => { const nftParentTxHistory = await getAllTxHistoryByTokenId( chronik, @@ -1610,149 +1635,138 @@ // Clear the offer // Note this will also clear the confirmation modal setPreviewedAgoraPartial(null); + + // Refresh the Orderbook + // TODO the Orderbook component should have a ws subscription to agora offers of its tokenId + checkForActiveOffers(); }; - return ( + return !loading && + typeof cashtabCache.tokens.get(tokenId) !== 'undefined' ? ( <> - {tokenBalance && - typeof cashtabCache.tokens.get(tokenId) !== 'undefined' && ( - <> - {showTokenTypeInfo && ( - <Modal - title={renderedTokenType} - description={renderedTokenDescription} - handleOk={() => setShowTokenTypeInfo(false)} - handleCancel={() => setShowTokenTypeInfo(false)} - /> - )} - {showAgoraPartialInfo && ( - <Modal - title={`Sell Tokens`} - description={`List tokens for sale with Agora Partial offers. Decide how many tokens you would like to sell, the minimum amount a user must buy to accept an offer, and the price per token. Due to encoding, input values here are approximate. The actual offer may have slightly different parameters. Price can be set lower than 1 XEC per token (no lower than 1 nanosat per 1 token satoshi). To ensure accurate pricing, the minimum buy should be set to at least 0.1% of the total tokens offered.`} - handleOk={() => setShowAgoraPartialInfo(false)} - handleCancel={() => - setShowAgoraPartialInfo(false) - } - /> - )} - {showFanoutInfo && ( - <Modal - title="Creating NFT mint inputs" - handleOk={() => setShowFanoutInfo(false)} - handleCancel={() => setShowFanoutInfo(false)} - height={300} - > - <InfoModalParagraph> - A genesis tx for an NFT collection - determines the size of your NFT collection. - </InfoModalParagraph> - <InfoModalParagraph> - For example, if you created an NFT - Collection with a supply of 100, you can - mint 100 NFTs.{' '} - </InfoModalParagraph> - <InfoModalParagraph> - However, each NFT must be minted from an - input UTXO with qty 1. Cashtab creates these - by splitting your original UTXO into utxos - with qty 1.{' '} - </InfoModalParagraph> - <InfoModalParagraph> - These qty 1 NFT Collection utxos can be used - to mint NFTs. - </InfoModalParagraph> - </Modal> - )} - {showMintNftInfo && ( - <Modal - title="Minting an NFT" - handleOk={() => setShowMintNftInfo(false)} - handleCancel={() => setShowMintNftInfo(false)} - height={300} - > - <InfoModalParagraph> - You can use an NFT Mint Input (a qty-1 utxo - from an NFT Collection token) to mint an - NFT. - </InfoModalParagraph> - <InfoModalParagraph> - NFTs from the same Collection are usually - related somehow. They will be indexed by the - tokenId of the NFT Collection. - </InfoModalParagraph> - <InfoModalParagraph> - For example, popular NFT Collections include - Cryptopunks and Bored Apes. Each individual - Cryptopunk or Bored Ape is its own NFT. - </InfoModalParagraph> - </Modal> - )} - {showLargeIconModal && ( - <Modal - height={275} - showButtons={false} - handleCancel={() => - setShowLargeIconModal(false) - } - > - <TokenIcon size={256} tokenId={tokenId} /> - </Modal> - )} - {showLargeNftIcon !== '' && ( - <Modal - height={275} - showButtons={false} - handleCancel={() => setShowLargeNftIcon('')} - > - <TokenIcon - size={256} - tokenId={showLargeNftIcon} - /> - </Modal> - )} - {isModalVisible && ( - <Modal - title="Confirm Send" - description={`Send ${formData.amount}${' '} + {showTokenTypeInfo && ( + <Modal + title={renderedTokenType} + description={renderedTokenDescription} + handleOk={() => setShowTokenTypeInfo(false)} + handleCancel={() => setShowTokenTypeInfo(false)} + /> + )} + {showAgoraPartialInfo && ( + <Modal + title={`Sell Tokens`} + description={`List tokens for sale with Agora Partial offers. Decide how many tokens you would like to sell, the minimum amount a user must buy to accept an offer, and the price per token. Due to encoding, input values here are approximate. The actual offer may have slightly different parameters. Price can be set lower than 1 XEC per token (no lower than 1 nanosat per 1 token satoshi). To ensure accurate pricing, the minimum buy should be set to at least 0.1% of the total tokens offered.`} + handleOk={() => setShowAgoraPartialInfo(false)} + handleCancel={() => setShowAgoraPartialInfo(false)} + /> + )} + {showFanoutInfo && ( + <Modal + title="Creating NFT mint inputs" + handleOk={() => setShowFanoutInfo(false)} + handleCancel={() => setShowFanoutInfo(false)} + height={300} + > + <InfoModalParagraph> + A genesis tx for an NFT collection determines the size + of your NFT collection. + </InfoModalParagraph> + <InfoModalParagraph> + For example, if you created an NFT Collection with a + supply of 100, you can mint 100 NFTs.{' '} + </InfoModalParagraph> + <InfoModalParagraph> + However, each NFT must be minted from an input UTXO with + qty 1. Cashtab creates these by splitting your original + UTXO into utxos with qty 1.{' '} + </InfoModalParagraph> + <InfoModalParagraph> + These qty 1 NFT Collection utxos can be used to mint + NFTs. + </InfoModalParagraph> + </Modal> + )} + {showMintNftInfo && ( + <Modal + title="Minting an NFT" + handleOk={() => setShowMintNftInfo(false)} + handleCancel={() => setShowMintNftInfo(false)} + height={300} + > + <InfoModalParagraph> + You can use an NFT Mint Input (a qty-1 utxo from an NFT + Collection token) to mint an NFT. + </InfoModalParagraph> + <InfoModalParagraph> + NFTs from the same Collection are usually related + somehow. They will be indexed by the tokenId of the NFT + Collection. + </InfoModalParagraph> + <InfoModalParagraph> + For example, popular NFT Collections include Cryptopunks + and Bored Apes. Each individual Cryptopunk or Bored Ape + is its own NFT. + </InfoModalParagraph> + </Modal> + )} + {showLargeIconModal && ( + <Modal + height={275} + showButtons={false} + handleCancel={() => setShowLargeIconModal(false)} + > + <TokenIcon size={256} tokenId={tokenId} /> + </Modal> + )} + {showLargeNftIcon !== '' && ( + <Modal + height={275} + showButtons={false} + handleCancel={() => setShowLargeNftIcon('')} + > + <TokenIcon size={256} tokenId={showLargeNftIcon} /> + </Modal> + )} + {isModalVisible && ( + <Modal + title="Confirm Send" + description={`Send ${formData.amount}${' '} ${tokenTicker} to ${formData.address}?`} - handleOk={handleOk} - handleCancel={handleCancel} - showCancelButton - > - <p> - Are you sure you want to send{' '} - {formData.amount} {tokenTicker} to{' '} - {formData.address}? - </p> - </Modal> - )} - {showConfirmBurnEtoken && ( - <Modal - title={`Confirm ${tokenTicker} burn`} - description={`Burn ${formData.burnAmount} ${tokenTicker}?`} - handleOk={burn} - handleCancel={() => - setShowConfirmBurnEtoken(false) - } - showCancelButton - height={250} - > - <ModalInput - placeholder={`Type "burn ${tokenTicker}" to confirm`} - name="etokenToBeBurnt" - value={confirmationOfEtokenToBeBurnt} - error={burnConfirmationError} - handleInput={handleBurnConfirmationInput} - /> - </Modal> - )} - {showConfirmListNft && formData.nftListPrice !== '' && ( - <Modal - title={`List ${tokenTicker} for ${ - selectedCurrency === appConfig.ticker - ? `${parseFloat( - formData.nftListPrice, - ).toLocaleString(userLocale)} + handleOk={handleOk} + handleCancel={handleCancel} + showCancelButton + > + <p> + Are you sure you want to send {formData.amount}{' '} + {tokenTicker} to {formData.address}? + </p> + </Modal> + )} + {showConfirmBurnEtoken && ( + <Modal + title={`Confirm ${tokenTicker} burn`} + description={`Burn ${formData.burnAmount} ${tokenTicker}?`} + handleOk={burn} + handleCancel={() => setShowConfirmBurnEtoken(false)} + showCancelButton + height={250} + > + <ModalInput + placeholder={`Type "burn ${tokenTicker}" to confirm`} + name="etokenToBeBurnt" + value={confirmationOfEtokenToBeBurnt} + error={burnConfirmationError} + handleInput={handleBurnConfirmationInput} + /> + </Modal> + )} + {showConfirmListNft && formData.nftListPrice !== '' && ( + <Modal + title={`List ${tokenTicker} for ${ + selectedCurrency === appConfig.ticker + ? `${parseFloat( + formData.nftListPrice, + ).toLocaleString(userLocale)} XEC (${ settings ? `${ @@ -1763,463 +1777,422 @@ } ` : '$ ' }${( - parseFloat( - formData.nftListPrice, - ) * fiatPrice - ).toLocaleString(userLocale, { - minimumFractionDigits: - appConfig.cashDecimals, - maximumFractionDigits: - appConfig.cashDecimals, - })} ${ - settings && settings.fiatCurrency - ? settings.fiatCurrency.toUpperCase() - : 'USD' - })?` - : `${ - settings - ? `${ - supportedFiatCurrencies[ - settings - .fiatCurrency - ].symbol - } ` - : '$ ' - }${parseFloat( - formData.nftListPrice, - ).toLocaleString(userLocale)} ${ - settings && settings.fiatCurrency - ? settings.fiatCurrency.toUpperCase() - : 'USD' - } (${( - parseFloat( - formData.nftListPrice, - ) / fiatPrice - ).toLocaleString(userLocale, { - minimumFractionDigits: - appConfig.cashDecimals, - maximumFractionDigits: - appConfig.cashDecimals, - })} + parseFloat(formData.nftListPrice) * fiatPrice + ).toLocaleString(userLocale, { + minimumFractionDigits: appConfig.cashDecimals, + maximumFractionDigits: appConfig.cashDecimals, + })} ${ + settings && settings.fiatCurrency + ? settings.fiatCurrency.toUpperCase() + : 'USD' + })?` + : `${ + settings + ? `${ + supportedFiatCurrencies[ + settings.fiatCurrency + ].symbol + } ` + : '$ ' + }${parseFloat( + formData.nftListPrice, + ).toLocaleString(userLocale)} ${ + settings && settings.fiatCurrency + ? settings.fiatCurrency.toUpperCase() + : 'USD' + } (${( + parseFloat(formData.nftListPrice) / fiatPrice + ).toLocaleString(userLocale, { + minimumFractionDigits: appConfig.cashDecimals, + maximumFractionDigits: appConfig.cashDecimals, + })} XEC)?` - }`} - handleOk={listNft} - handleCancel={() => - setShowConfirmListNft(false) - } - showCancelButton - description={`This will create a sell offer. Your NFT is only transferred if your full price is paid. The price is fixed in XEC. If your NFT is not purchased, you can cancel or renew your listing at any time.`} - height={275} + }`} + handleOk={listNft} + handleCancel={() => setShowConfirmListNft(false)} + showCancelButton + description={`This will create a sell offer. Your NFT is only transferred if your full price is paid. The price is fixed in XEC. If your NFT is not purchased, you can cancel or renew your listing at any time.`} + height={275} + /> + )} + {showConfirmListPartialSlp && + formData.slpListPrice !== '' && + previewedAgoraPartial !== null && ( + <Modal + title={`List ${tokenTicker}?`} + handleOk={listSlpPartial} + handleCancel={() => setPreviewedAgoraPartial(null)} + showCancelButton + height={450} + > + <AgoraPreviewParagraph> + Agora offers require special encoding and may not + match your input. + </AgoraPreviewParagraph> + <AgoraPreviewParagraph> + Create the following sell offer? + </AgoraPreviewParagraph> + + <AgoraPreviewTable> + <AgoraPreviewRow> + <AgoraPreviewLabel> + Offered qty:{' '} + </AgoraPreviewLabel> + <AgoraPreviewCol> + {decimalizeTokenAmount( + previewedAgoraPartial + .offeredTokens() + .toString(), + decimals, + )} + </AgoraPreviewCol> + </AgoraPreviewRow> + <AgoraPreviewRow> + <AgoraPreviewLabel>Min buy: </AgoraPreviewLabel> + <AgoraPreviewCol> + {decimalizeTokenAmount( + previewedAgoraPartial + .minAcceptedTokens() + .toString(), + decimals, + )} + </AgoraPreviewCol> + </AgoraPreviewRow> + + <AgoraPreviewRow> + <AgoraPreviewLabel> + Actual price:{' '} + </AgoraPreviewLabel> + <AgoraPreviewCol> + {getAgoraPartialActualPrice()} + </AgoraPreviewCol> + </AgoraPreviewRow> + <AgoraPreviewRow> + <AgoraPreviewLabel> + Target price:{' '} + </AgoraPreviewLabel> + <AgoraPreviewCol> + {getAgoraPartialTargetPriceXec()} + </AgoraPreviewCol> + </AgoraPreviewRow> + </AgoraPreviewTable> + <AgoraPreviewParagraph> + If actual price is not close to target price, + increase your min buy. + </AgoraPreviewParagraph> + <AgoraPreviewParagraph> + You can cancel this listing at any time. + </AgoraPreviewParagraph> + </Modal> + )} + {renderedTokenType === 'NFT' ? ( + <> + <NftNameTitle>{tokenName}</NftNameTitle> + {typeof cachedInfo.groupTokenId !== 'undefined' && + typeof cashtabCache.tokens.get( + cachedInfo.groupTokenId, + ) !== 'undefined' && ( + <NftCollectionTitle> + NFT from collection " + <Link to={`/token/${cachedInfo.groupTokenId}`}> + { + cashtabCache.tokens.get( + cachedInfo.groupTokenId, + ).genesisInfo.tokenName + } + </Link> + " + </NftCollectionTitle> + )} + </> + ) : ( + <BalanceHeaderToken + formattedDecimalizedTokenBalance={ + typeof tokenBalance === 'string' + ? decimalizedTokenQtyToLocaleFormat( + tokenBalance, + userLocale, + ) + : null + } + ticker={tokenTicker} + name={tokenName} + /> + )} + <TokenStatsTable title="Token Stats"> + <TokenStatsCol> + <TokenIconExpandButton + onClick={() => setShowLargeIconModal(true)} + > + <TokenIcon size={128} tokenId={tokenId} /> + </TokenIconExpandButton> + </TokenStatsCol> + <TokenStatsCol> + <TokenStatsTableRow> + <TokenStatsLabel>Type:</TokenStatsLabel> + <TokenStatsCol> + <DataAndQuestionButton> + {renderedTokenType}{' '} + <IconButton + name={`Click for more info about this token type`} + icon={<QuestionIcon />} + onClick={() => setShowTokenTypeInfo(true)} + /> + </DataAndQuestionButton> + </TokenStatsCol> + </TokenStatsTableRow> + <TokenStatsTableRow> + <TokenStatsLabel>Token Id:</TokenStatsLabel> + <TokenStatsCol> + <a + href={`${explorer.blockExplorerUrl}/tx/${tokenId}`} + target="_blank" + rel="noopener noreferrer" + > + {tokenId.slice(0, 3)}... + {tokenId.slice(-3)} + </a> + </TokenStatsCol> + <TokenStatsCol> + <CopyIconButton + data={tokenId} + showToast + customMsg={`Token ID "${tokenId}" copied to clipboard`} /> + </TokenStatsCol> + </TokenStatsTableRow> + {renderedTokenType !== 'NFT' && + renderedTokenType !== 'NFT Collection' && ( + <TokenStatsTableRow> + <TokenStatsLabel>decimals:</TokenStatsLabel> + <TokenStatsCol>{decimals}</TokenStatsCol> + </TokenStatsTableRow> )} - {showConfirmListPartialSlp && - formData.slpListPrice !== '' && - previewedAgoraPartial !== null && ( - <Modal - title={`List ${tokenTicker}?`} - handleOk={listSlpPartial} - handleCancel={() => - setPreviewedAgoraPartial(null) + {url !== '' && ( + <TokenStatsTableRow> + <TokenStatsLabel>url:</TokenStatsLabel> + <TokenUrlCol> + <a + href={ + url.startsWith('https://') + ? url + : `https://${url}` } - showCancelButton - height={450} + target="_blank" + rel="noreferrer" > - <AgoraPreviewParagraph> - Agora offers require special encoding - and may not match your input. - </AgoraPreviewParagraph> - <AgoraPreviewParagraph> - Create the following sell offer? - </AgoraPreviewParagraph> - - <AgoraPreviewTable> - <AgoraPreviewRow> - <AgoraPreviewLabel> - Offered qty:{' '} - </AgoraPreviewLabel> - <AgoraPreviewCol> - {decimalizeTokenAmount( - previewedAgoraPartial - .offeredTokens() - .toString(), - decimals, - )} - </AgoraPreviewCol> - </AgoraPreviewRow> - <AgoraPreviewRow> - <AgoraPreviewLabel> - Min buy:{' '} - </AgoraPreviewLabel> - <AgoraPreviewCol> - {decimalizeTokenAmount( - previewedAgoraPartial - .minAcceptedTokens() - .toString(), - decimals, - )} - </AgoraPreviewCol> - </AgoraPreviewRow> - - <AgoraPreviewRow> - <AgoraPreviewLabel> - Actual price:{' '} - </AgoraPreviewLabel> - <AgoraPreviewCol> - {getAgoraPartialActualPrice()} - </AgoraPreviewCol> - </AgoraPreviewRow> - <AgoraPreviewRow> - <AgoraPreviewLabel> - Target price:{' '} - </AgoraPreviewLabel> - <AgoraPreviewCol> - {getAgoraPartialTargetPriceXec()} - </AgoraPreviewCol> - </AgoraPreviewRow> - </AgoraPreviewTable> - <AgoraPreviewParagraph> - If actual price is not close to target - price, increase your min buy. - </AgoraPreviewParagraph> - <AgoraPreviewParagraph> - You can cancel this listing at any time. - </AgoraPreviewParagraph> - </Modal> - )} - {renderedTokenType === 'NFT' ? ( - <> - <NftNameTitle>{tokenName}</NftNameTitle> - {typeof cachedInfo.groupTokenId !== - 'undefined' && - typeof cashtabCache.tokens.get( - cachedInfo.groupTokenId, - ) !== 'undefined' && ( - <NftCollectionTitle> - NFT from collection " - <Link - to={`/token/${cachedInfo.groupTokenId}`} - > - { - cashtabCache.tokens.get( - cachedInfo.groupTokenId, - ).genesisInfo.tokenName - } - </Link> - " - </NftCollectionTitle> - )} - </> - ) : ( - <BalanceHeaderToken - formattedDecimalizedTokenBalance={ - typeof tokenBalance === 'string' - ? decimalizedTokenQtyToLocaleFormat( - tokenBalance, - userLocale, - ) - : null - } - ticker={tokenTicker} - name={tokenName} - /> - )} - <TokenStatsTable title="Token Stats"> + {`${url.slice( + url.startsWith('https://') ? 8 : 0, + )}`} + </a> + </TokenUrlCol> + </TokenStatsTableRow> + )} + <TokenStatsTableRow> + <TokenStatsLabel>created:</TokenStatsLabel> + <TokenStatsCol> + {typeof cachedInfo.block !== 'undefined' + ? formatDate( + cachedInfo.block.timestamp, + navigator.language, + ) + : formatDate( + cachedInfo.timeFirstSeen, + navigator.language, + )} + </TokenStatsCol> + </TokenStatsTableRow> + {renderedTokenType !== 'NFT' && ( + <TokenStatsTableRow> + <TokenStatsLabel>Genesis Qty:</TokenStatsLabel> <TokenStatsCol> - <TokenIconExpandButton - onClick={() => setShowLargeIconModal(true)} - > - <TokenIcon size={128} tokenId={tokenId} /> - </TokenIconExpandButton> + {typeof genesisSupply === 'string' ? ( + decimalizedTokenQtyToLocaleFormat( + genesisSupply, + userLocale, + ) + ) : ( + <InlineLoader /> + )} </TokenStatsCol> + </TokenStatsTableRow> + )} + {renderedTokenType !== 'NFT' && ( + <TokenStatsTableRow> + <TokenStatsLabel>Supply:</TokenStatsLabel> <TokenStatsCol> - <TokenStatsTableRow> - <TokenStatsLabel>Type:</TokenStatsLabel> - <TokenStatsCol> - <DataAndQuestionButton> - {renderedTokenType}{' '} - <IconButton - name={`Click for more info about this token type`} - icon={<QuestionIcon />} - onClick={() => - setShowTokenTypeInfo(true) - } - /> - </DataAndQuestionButton> - </TokenStatsCol> - </TokenStatsTableRow> - <TokenStatsTableRow> - <TokenStatsLabel>Token Id:</TokenStatsLabel> - <TokenStatsCol> - <a - href={`${explorer.blockExplorerUrl}/tx/${tokenId}`} - target="_blank" - rel="noopener noreferrer" + {typeof uncachedTokenInfo.circulatingSupply === + 'string' ? ( + `${decimalizedTokenQtyToLocaleFormat( + uncachedTokenInfo.circulatingSupply, + userLocale, + )}${ + uncachedTokenInfo.mintBatons === 0 + ? ' (fixed)' + : ' (var.)' + }` + ) : uncachedTokenInfoError ? ( + 'Error fetching supply' + ) : ( + <InlineLoader /> + )} + </TokenStatsCol> + </TokenStatsTableRow> + )} + {typeof hash !== 'undefined' && hash !== '' && ( + <TokenStatsTableRow> + <TokenStatsLabel>hash:</TokenStatsLabel> + <TokenStatsCol> + {hash.slice(0, 3)}... + {hash.slice(-3)} + </TokenStatsCol> + <TokenStatsCol> + <CopyIconButton + data={hash} + showToast + customMsg={`Token document hash "${hash}" copied to clipboard`} + /> + </TokenStatsCol> + </TokenStatsTableRow> + )} + </TokenStatsCol> + </TokenStatsTable> + + {isNftParent && nftTokenIds.length > 0 && ( + <> + <NftTitle>NFTs in this Collection</NftTitle> + <NftTable> + {nftTokenIds.map(nftTokenId => { + const cachedNftInfo = + cashtabCache.tokens.get(nftTokenId); + return ( + <NftCol key={nftTokenId}> + <NftRow> + <TokenIconExpandButton + onClick={() => + setShowLargeNftIcon(nftTokenId) + } > - {tokenId.slice(0, 3)}... - {tokenId.slice(-3)} - </a> - </TokenStatsCol> - <TokenStatsCol> - <CopyIconButton - data={tokenId} - showToast - customMsg={`Token ID "${tokenId}" copied to clipboard`} - /> - </TokenStatsCol> - </TokenStatsTableRow> - {renderedTokenType !== 'NFT' && - renderedTokenType !== 'NFT Collection' && ( - <TokenStatsTableRow> - <TokenStatsLabel> - decimals: - </TokenStatsLabel> - <TokenStatsCol> - {decimals} - </TokenStatsCol> - </TokenStatsTableRow> - )} - {url !== '' && ( - <TokenStatsTableRow> - <TokenStatsLabel>url:</TokenStatsLabel> - <TokenUrlCol> + <TokenIcon + size={64} + tokenId={nftTokenId} + /> + </TokenIconExpandButton> + </NftRow> + <NftRow> + <NftTokenIdAndCopyIcon> <a - href={ - url.startsWith('https://') - ? url - : `https://${url}` - } + href={`${explorer.blockExplorerUrl}/tx/${nftTokenId}`} target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > - {`${url.slice( - url.startsWith('https://') - ? 8 - : 0, - )}`} + {nftTokenId.slice(0, 3)} + ... + {nftTokenId.slice(-3)} </a> - </TokenUrlCol> - </TokenStatsTableRow> - )} - <TokenStatsTableRow> - <TokenStatsLabel>created:</TokenStatsLabel> - <TokenStatsCol> - {typeof cachedInfo.block !== 'undefined' - ? formatDate( - cachedInfo.block.timestamp, - navigator.language, - ) - : formatDate( - cachedInfo.timeFirstSeen, - navigator.language, - )} - </TokenStatsCol> - </TokenStatsTableRow> - {renderedTokenType !== 'NFT' && ( - <TokenStatsTableRow> - <TokenStatsLabel> - Genesis Qty: - </TokenStatsLabel> - <TokenStatsCol> - {typeof genesisSupply === - 'string' ? ( - decimalizedTokenQtyToLocaleFormat( - genesisSupply, - userLocale, - ) - ) : ( - <InlineLoader /> - )} - </TokenStatsCol> - </TokenStatsTableRow> - )} - {renderedTokenType !== 'NFT' && ( - <TokenStatsTableRow> - <TokenStatsLabel> - Supply: - </TokenStatsLabel> - <TokenStatsCol> - {typeof uncachedTokenInfo.circulatingSupply === - 'string' ? ( - `${decimalizedTokenQtyToLocaleFormat( - uncachedTokenInfo.circulatingSupply, - userLocale, - )}${ - uncachedTokenInfo.mintBatons === - 0 - ? ' (fixed)' - : ' (var.)' - }` - ) : uncachedTokenInfoError ? ( - 'Error fetching supply' - ) : ( - <InlineLoader /> - )} - </TokenStatsCol> - </TokenStatsTableRow> - )} - {typeof hash !== 'undefined' && hash !== '' && ( - <TokenStatsTableRow> - <TokenStatsLabel>hash:</TokenStatsLabel> - <TokenStatsCol> - {hash.slice(0, 3)}... - {hash.slice(-3)} - </TokenStatsCol> - <TokenStatsCol> <CopyIconButton - data={hash} + data={nftTokenId} showToast - customMsg={`Token document hash "${hash}" copied to clipboard`} + customMsg={`NFT Token ID "${nftTokenId}" copied to clipboard`} /> - </TokenStatsCol> - </TokenStatsTableRow> - )} - </TokenStatsCol> - </TokenStatsTable> - - {isNftParent && nftTokenIds.length > 0 && ( - <> - <NftTitle>NFTs in this Collection</NftTitle> - <NftTable> - {nftTokenIds.map(nftTokenId => { - const cachedNftInfo = - cashtabCache.tokens.get(nftTokenId); - return ( - <NftCol key={nftTokenId}> - <NftRow> - <TokenIconExpandButton - onClick={() => - setShowLargeNftIcon( - nftTokenId, - ) - } + </NftTokenIdAndCopyIcon> + </NftRow> + {typeof cachedNftInfo !== 'undefined' && ( + <> + <NftRow> + {typeof tokens.get( + nftTokenId, + ) !== 'undefined' ? ( + <Link + to={`/token/${nftTokenId}`} > - <TokenIcon - size={64} - tokenId={nftTokenId} - /> - </TokenIconExpandButton> - </NftRow> - <NftRow> - <NftTokenIdAndCopyIcon> - <a - href={`${explorer.blockExplorerUrl}/tx/${nftTokenId}`} - target="_blank" - rel="noopener noreferrer" - > - {nftTokenId.slice( - 0, - 3, - )} - ... - {nftTokenId.slice( - -3, - )} - </a> - <CopyIconButton - data={nftTokenId} - showToast - customMsg={`NFT Token ID "${nftTokenId}" copied to clipboard`} - /> - </NftTokenIdAndCopyIcon> - </NftRow> - {typeof cachedNftInfo !== - 'undefined' && ( - <> - <NftRow> - {typeof tokens.get( - nftTokenId, - ) !== - 'undefined' ? ( - <Link - to={`/token/${nftTokenId}`} - > - { - cachedNftInfo - .genesisInfo - .tokenName - } - </Link> - ) : ( - cachedNftInfo - .genesisInfo - .tokenName - )} - </NftRow> - </> + { + cachedNftInfo + .genesisInfo + .tokenName + } + </Link> + ) : ( + cachedNftInfo.genesisInfo + .tokenName )} - </NftCol> - ); - })} - </NftTable> - </> - )} - {apiError && <ApiError />} - - {isSupportedToken && ( - <SendTokenForm title="Token Actions"> - {isNftChild ? ( + </NftRow> + </> + )} + </NftCol> + ); + })} + </NftTable> + </> + )} + {apiError && <ApiError />} + {typeof tokenBalance === 'undefined' && ( + <Info>You do not hold this token.</Info> + )} + + {isSupportedToken && typeof tokenBalance !== 'undefined' && ( + <> + <SubHeading>Available Actions</SubHeading> + <SendTokenForm title="Token Actions"> + {isNftChild ? ( + <> + <SwitchHolder> + <Switch + name="Toggle Sell NFT" + on="💰" + off="💰" + checked={switches.showSellNft} + handleToggle={() => { + // We turn everything else off, whether we are turning this one on or off + setSwitches({ + ...switchesOff, + showSellNft: + !switches.showSellNft, + }); + }} + /> + <SwitchLabel> + Sell {tokenName} ({tokenTicker}) + </SwitchLabel> + </SwitchHolder> + {switches.showSellNft && ( <> - <SwitchHolder> - <Switch - name="Toggle Sell NFT" - on="💰" - off="💰" - checked={switches.showSellNft} - handleToggle={() => { - // We turn everything else off, whether we are turning this one on or off - setSwitches({ - ...switchesOff, - showSellNft: - !switches.showSellNft, - }); - }} - /> - <SwitchLabel> - Sell {tokenName} ({tokenTicker}) - </SwitchLabel> - </SwitchHolder> - {switches.showSellNft && ( - <> - <SendTokenFormRow> - <InputRow> - <ListPriceInput - name="nftListPrice" - placeholder="Enter NFT list price" - value={ - formData.nftListPrice - } - selectValue={ - selectedCurrency - } - selectDisabled={ - fiatPrice === - null - } - fiatCode={settings.fiatCurrency.toUpperCase()} - error={ - nftListPriceError - } - handleInput={ - handleNftListPriceChange - } - handleSelect={ - handleSelectedCurrencyChange - } - ></ListPriceInput> - </InputRow> - </SendTokenFormRow> - {!nftListPriceError && - formData.nftListPrice !== - '' && - fiatPrice !== null && ( - <ListPricePreview title="NFT List Price"> - {selectedCurrency === - appConfig.ticker - ? `${parseFloat( - formData.nftListPrice, - ).toLocaleString( - userLocale, - )} + <SendTokenFormRow> + <InputRow> + <ListPriceInput + name="nftListPrice" + placeholder="Enter NFT list price" + value={ + formData.nftListPrice + } + selectValue={ + selectedCurrency + } + selectDisabled={ + fiatPrice === null + } + fiatCode={settings.fiatCurrency.toUpperCase()} + error={nftListPriceError} + handleInput={ + handleNftListPriceChange + } + handleSelect={ + handleSelectedCurrencyChange + } + ></ListPriceInput> + </InputRow> + </SendTokenFormRow> + {!nftListPriceError && + formData.nftListPrice !== '' && + fiatPrice !== null && ( + <ListPricePreview title="NFT List Price"> + {selectedCurrency === + appConfig.ticker + ? `${parseFloat( + formData.nftListPrice, + ).toLocaleString( + userLocale, + )} XEC = ${ settings ? `${ @@ -2230,626 +2203,624 @@ } ` : '$ ' }${( - parseFloat( - formData.nftListPrice, - ) * - fiatPrice - ).toLocaleString( - userLocale, - { - minimumFractionDigits: - appConfig.cashDecimals, - maximumFractionDigits: - appConfig.cashDecimals, - }, - )} ${ - settings && - settings.fiatCurrency - ? settings.fiatCurrency.toUpperCase() - : 'USD' - }` - : `${ - settings - ? `${ - supportedFiatCurrencies[ - settings - .fiatCurrency - ] - .symbol - } ` - : '$ ' - }${parseFloat( - formData.nftListPrice, - ).toLocaleString( - userLocale, - )} ${ - settings && - settings.fiatCurrency - ? settings.fiatCurrency.toUpperCase() - : 'USD' - } = ${( - parseFloat( - formData.nftListPrice, - ) / - fiatPrice - ).toLocaleString( - userLocale, - { - minimumFractionDigits: - appConfig.cashDecimals, - maximumFractionDigits: - appConfig.cashDecimals, - }, - )} + parseFloat( + formData.nftListPrice, + ) * fiatPrice + ).toLocaleString( + userLocale, + { + minimumFractionDigits: + appConfig.cashDecimals, + maximumFractionDigits: + appConfig.cashDecimals, + }, + )} ${ + settings && + settings.fiatCurrency + ? settings.fiatCurrency.toUpperCase() + : 'USD' + }` + : `${ + settings + ? `${ + supportedFiatCurrencies[ + settings + .fiatCurrency + ].symbol + } ` + : '$ ' + }${parseFloat( + formData.nftListPrice, + ).toLocaleString( + userLocale, + )} ${ + settings && + settings.fiatCurrency + ? settings.fiatCurrency.toUpperCase() + : 'USD' + } = ${( + parseFloat( + formData.nftListPrice, + ) / fiatPrice + ).toLocaleString( + userLocale, + { + minimumFractionDigits: + appConfig.cashDecimals, + maximumFractionDigits: + appConfig.cashDecimals, + }, + )} XEC`} - </ListPricePreview> - )} - <SendTokenFormRow> - <PrimaryButton - style={{ - marginTop: '12px', - }} - disabled={ - apiError || - nftListPriceError || - formData.nftListPrice === - '' - } - onClick={() => - setShowConfirmListNft( - true, - ) - } - > - List {tokenName} - </PrimaryButton> - </SendTokenFormRow> - </> - )} + </ListPricePreview> + )} + <SendTokenFormRow> + <PrimaryButton + style={{ + marginTop: '12px', + }} + disabled={ + apiError || + nftListPriceError || + formData.nftListPrice === '' + } + onClick={() => + setShowConfirmListNft(true) + } + > + List {tokenName} + </PrimaryButton> + </SendTokenFormRow> </> - ) : ( - tokenType.type === - 'SLP_TOKEN_TYPE_FUNGIBLE' && ( + )} + </> + ) : ( + tokenType.type === 'SLP_TOKEN_TYPE_FUNGIBLE' && ( + <> + <SwitchHolder> + <Switch + name="Toggle Sell SLP" + on="💰" + off="💰" + checked={switches.showSellSlp} + handleToggle={() => { + // We turn everything else off, whether we are turning this one on or off + setSwitches({ + ...switchesOff, + showSellSlp: + !switches.showSellSlp, + }); + }} + /> + <SwitchLabel> + Sell {tokenName} ({tokenTicker}) + </SwitchLabel> + <IconButton + name={`Click for more info about agora partial sales`} + icon={<QuestionIcon />} + onClick={() => + setShowAgoraPartialInfo(true) + } + /> + </SwitchHolder> + + {switches.showSellSlp && ( <> - <SwitchHolder> - <Switch - name="Toggle Sell SLP" - on="💰" - off="💰" - checked={ - switches.showSellSlp - } - handleToggle={() => { - // We turn everything else off, whether we are turning this one on or off - setSwitches({ - ...switchesOff, - showSellSlp: - !switches.showSellSlp, - }); - }} - /> - <SwitchLabel> - Sell {tokenName} ( - {tokenTicker}) - </SwitchLabel> - <IconButton - name={`Click for more info about agora partial sales`} - icon={<QuestionIcon />} - onClick={() => - setShowAgoraPartialInfo( - true, - ) - } - /> - </SwitchHolder> - - {switches.showSellSlp && ( - <> - <SendTokenFormRow> - <InputRow> - <Slider - name={ - 'slpAgoraPartialTokenQty' - } - label={`Offered qty`} - value={ - slpAgoraPartialTokenQty - } - handleSlide={ - handleSlpOfferedSlide - } - error={ - slpAgoraPartialTokenQtyError - } - min={0} - max={ - tokenBalance - } - // Step is 1 smallets supported decimal point of the given token - step={parseFloat( - `1e-${decimals}`, - )} - allowTypedInput - /> - </InputRow> - </SendTokenFormRow> - <SendTokenFormRow> - <InputRow> - <Slider - name={ - 'slpAgoraPartialMin' - } - label={`Min buy`} - value={ - slpAgoraPartialMin - } - handleSlide={ - handleSlpMinSlide - } - error={ - slpAgoraPartialMinError - } - min={0} - max={ - slpAgoraPartialTokenQty - } - // Step is 1 smallets supported decimal point of the given token - step={parseFloat( - `1e-${decimals}`, - )} - allowTypedInput - /> - </InputRow> - </SendTokenFormRow> - <SendTokenFormRow> - <InputRow> - <ListPriceInput - name="slpListPrice" - placeholder="Enter SLP list price (per token)" - inputDisabled={ - slpAgoraPartialMin === - '0' - } - value={Number( - formData.slpListPrice, - )} - selectValue={ - selectedCurrency - } - selectDisabled={ - fiatPrice === - null - } - fiatCode={settings.fiatCurrency.toUpperCase()} - error={ - slpListPriceError - } - handleInput={ - handleSlpListPriceChange - } - handleSelect={ - handleSelectedCurrencyChange - } - ></ListPriceInput> - </InputRow> - </SendTokenFormRow> - - {!slpListPriceError && - formData.slpListPrice !== - '' && - formData.slpListPrice !== - null && - fiatPrice !== null && ( - <ListPricePreview title="SLP List Price"> - {getAgoraPartialPricePreview()} - </ListPricePreview> + <SendTokenFormRow> + <InputRow> + <Slider + name={ + 'slpAgoraPartialTokenQty' + } + label={`Offered qty`} + value={ + slpAgoraPartialTokenQty + } + handleSlide={ + handleSlpOfferedSlide + } + error={ + slpAgoraPartialTokenQtyError + } + min={0} + max={tokenBalance} + // Step is 1 smallets supported decimal point of the given token + step={parseFloat( + `1e-${decimals}`, )} - <SendTokenFormRow> - <PrimaryButton - style={{ - marginTop: - '12px', - }} - disabled={ - apiError || - slpListPriceError || - formData.slpListPrice === - '' || - formData.slpListPrice === - null || - slpAgoraPartialTokenQty === - '0' || - slpAgoraPartialMin === - '0' - } - onClick={ - previewSlpPartial - } - > - List {tokenName} - </PrimaryButton> - </SendTokenFormRow> - </> - )} - </> - ) - )} - {!isNftParent && ( - <> - <SwitchHolder> - <Switch - name="Toggle Send" - on="➡️" - off="➡️" - checked={switches.showSend} - handleToggle={() => { - // We turn everything else off, whether we are turning this one on or off - setSwitches({ - ...switchesOff, - showSend: - !switches.showSend, - }); - }} - /> - <SwitchLabel> - Send {tokenName} ({tokenTicker}) - </SwitchLabel> - </SwitchHolder> - {switches.showSend && ( - <> - <SendTokenFormRow> - <InputRow> - <InputWithScanner - placeholder={ - aliasSettings.aliasEnabled - ? `Address or Alias` - : `Address` - } - name="address" - value={ - formData.address - } - handleInput={ - handleTokenAddressChange - } - error={ - sendTokenAddressError - } - loadWithScannerOpen={ - openWithScanner - } - /> - <AliasAddressPreviewLabel> - <TxLink - key={ - aliasInputAddress - } - href={`${explorer.blockExplorerUrl}/address/${aliasInputAddress}`} - target="_blank" - rel="noreferrer" - > - {aliasInputAddress && - `${aliasInputAddress.slice( - 0, - 10, - )}...${aliasInputAddress.slice( - -5, - )}`} - </TxLink> - </AliasAddressPreviewLabel> - </InputRow> - </SendTokenFormRow> - {!isNftChild && ( - <SendTokenFormRow> - <SendTokenInput - name="amount" - value={ - formData.amount - } - error={ - sendTokenAmountError - } - placeholder="Amount" - decimals={decimals} - handleInput={ - handleSlpAmountChange - } - handleOnMax={onMax} - /> - </SendTokenFormRow> - )} - <SendTokenFormRow> - <PrimaryButton - style={{ - marginTop: '12px', - }} - disabled={ - apiError || - sendTokenAmountError || - sendTokenAddressError || - formData.address === - '' || - (!isNftChild && - formData.amount === - '') + allowTypedInput + /> + </InputRow> + </SendTokenFormRow> + <SendTokenFormRow> + <InputRow> + <Slider + name={ + 'slpAgoraPartialMin' } - onClick={() => - checkForConfirmationBeforeSendEtoken() + label={`Min buy`} + value={ + slpAgoraPartialMin } - > - Send {tokenTicker} - </PrimaryButton> - </SendTokenFormRow> - </> - )} - </> - )} - {isNftParent && ( - <> - <SwitchHolder> - <Switch - name="Toggle NFT Parent Fan-out" - checked={switches.showFanout} - handleToggle={() => - // We turn everything else off, whether we are turning this one on or off - setSwitches({ - ...switchesOff, - showFanout: - !switches.showFanout, - }) - } - /> - <SwitchLabel> - <DataAndQuestionButton> - Create NFT mint inputs - <IconButton - name={`Click for more info about NFT Collection fan-out txs`} - icon={<QuestionIcon />} - onClick={() => - setShowFanoutInfo( - true, - ) + handleSlide={ + handleSlpMinSlide + } + error={ + slpAgoraPartialMinError + } + min={0} + max={ + slpAgoraPartialTokenQty } + // Step is 1 smallets supported decimal point of the given token + step={parseFloat( + `1e-${decimals}`, + )} + allowTypedInput /> - </DataAndQuestionButton> - </SwitchLabel> - </SwitchHolder> - {switches.showFanout && ( - <TokenStatsRow> - <SecondaryButton + </InputRow> + </SendTokenFormRow> + <SendTokenFormRow> + <InputRow> + <ListPriceInput + name="slpListPrice" + placeholder="Enter SLP list price (per token)" + inputDisabled={ + slpAgoraPartialMin === + '0' + } + value={Number( + formData.slpListPrice, + )} + selectValue={ + selectedCurrency + } + selectDisabled={ + fiatPrice === null + } + fiatCode={settings.fiatCurrency.toUpperCase()} + error={ + slpListPriceError + } + handleInput={ + handleSlpListPriceChange + } + handleSelect={ + handleSelectedCurrencyChange + } + ></ListPriceInput> + </InputRow> + </SendTokenFormRow> + + {!slpListPriceError && + formData.slpListPrice !== '' && + formData.slpListPrice !== + null && + fiatPrice !== null && ( + <ListPricePreview title="SLP List Price"> + {getAgoraPartialPricePreview()} + </ListPricePreview> + )} + <SendTokenFormRow> + <PrimaryButton style={{ marginTop: '12px', - marginBottom: '0px', }} disabled={ - nftFanInputs.length === - 0 - } - onClick={ - createNftMintInputs + apiError || + slpListPriceError || + formData.slpListPrice === + '' || + formData.slpListPrice === + null || + slpAgoraPartialTokenQty === + '0' || + slpAgoraPartialMin === + '0' } + onClick={previewSlpPartial} > - Create NFT Mint Inputs - </SecondaryButton> - <ButtonDisabledMsg> - {nftFanInputs.length === 0 - ? 'No token utxos exist with qty !== 1' - : ''} - </ButtonDisabledMsg> - </TokenStatsRow> + List {tokenName} + </PrimaryButton> + </SendTokenFormRow> + </> + )} + </> + ) + )} + {!isNftParent && ( + <> + <SwitchHolder> + <Switch + name="Toggle Send" + on="➡️" + off="➡️" + checked={switches.showSend} + handleToggle={() => { + // We turn everything else off, whether we are turning this one on or off + setSwitches({ + ...switchesOff, + showSend: !switches.showSend, + }); + }} + /> + <SwitchLabel> + Send {tokenName} ({tokenTicker}) + </SwitchLabel> + </SwitchHolder> + {switches.showSend && ( + <> + <SendTokenFormRow> + <InputRow> + <InputWithScanner + placeholder={ + aliasSettings.aliasEnabled + ? `Address or Alias` + : `Address` + } + name="address" + value={formData.address} + handleInput={ + handleTokenAddressChange + } + error={ + sendTokenAddressError + } + loadWithScannerOpen={ + openWithScanner + } + /> + <AliasAddressPreviewLabel> + <TxLink + key={aliasInputAddress} + href={`${explorer.blockExplorerUrl}/address/${aliasInputAddress}`} + target="_blank" + rel="noreferrer" + > + {aliasInputAddress && + `${aliasInputAddress.slice( + 0, + 10, + )}...${aliasInputAddress.slice( + -5, + )}`} + </TxLink> + </AliasAddressPreviewLabel> + </InputRow> + </SendTokenFormRow> + {!isNftChild && ( + <SendTokenFormRow> + <SendTokenInput + name="amount" + value={formData.amount} + error={sendTokenAmountError} + placeholder="Amount" + decimals={decimals} + handleInput={ + handleSlpAmountChange + } + handleOnMax={onMax} + /> + </SendTokenFormRow> )} - <SwitchHolder> - <Switch - name="Toggle Mint NFT" - checked={switches.showMintNft} + <SendTokenFormRow> + <PrimaryButton + style={{ + marginTop: '12px', + }} disabled={ - nftChildGenesisInput.length === - 0 + apiError || + sendTokenAmountError || + sendTokenAddressError || + formData.address === '' || + (!isNftChild && + formData.amount === '') } - handleToggle={() => - // We turn everything else off, whether we are turning this one on or off - setSwitches({ - ...switchesOff, - showMintNft: - !switches.showMintNft, - }) - } - /> - <SwitchLabel> - <DataAndQuestionButton> - Mint NFT{' '} - {availableNftInputs === - 0 ? ( - <ButtonDisabledSpan> - (no NFT mint - inputs) - </ButtonDisabledSpan> - ) : ( - <p> - ( - {availableNftInputs}{' '} - input - {availableNftInputs > - 1 - ? 's' - : ''}{' '} - available) - </p> - )} - <IconButton - name={`Click for more info about minting an NFT`} - icon={<QuestionIcon />} - onClick={() => - setShowMintNftInfo( - true, - ) - } - /> - </DataAndQuestionButton> - </SwitchLabel> - </SwitchHolder> - {switches.showMintNft && ( - <CreateTokenForm - nftChildGenesisInput={ - nftChildGenesisInput + onClick={() => + checkForConfirmationBeforeSendEtoken() } - /> - )} + > + Send {tokenTicker} + </PrimaryButton> + </SendTokenFormRow> </> )} - {!isNftChild && ( - <> - <SwitchHolder> - <Switch - name="Toggle Airdrop" - on="🪂" - off="🪂" - checked={switches.showAirdrop} - handleToggle={() => - // We turn everything else off, whether we are turning this one on or off - setSwitches({ - ...switchesOff, - showAirdrop: - !switches.showAirdrop, - }) + </> + )} + {isNftParent && ( + <> + <SwitchHolder> + <Switch + name="Toggle NFT Parent Fan-out" + checked={switches.showFanout} + handleToggle={() => + // We turn everything else off, whether we are turning this one on or off + setSwitches({ + ...switchesOff, + showFanout: + !switches.showFanout, + }) + } + /> + <SwitchLabel> + <DataAndQuestionButton> + Create NFT mint inputs + <IconButton + name={`Click for more info about NFT Collection fan-out txs`} + icon={<QuestionIcon />} + onClick={() => + setShowFanoutInfo(true) } /> - <SwitchLabel> - Airdrop XEC to {tokenTicker}{' '} - holders - </SwitchLabel> - </SwitchHolder> - {switches.showAirdrop && ( - <TokenStatsRow> - <Link - style={{ width: '100%' }} - to="/airdrop" - state={{ - airdropEtokenId: - tokenId, - }} - > - <SecondaryButton - style={{ - marginTop: '12px', - }} - > - Airdrop Calculator - </SecondaryButton> - </Link> - </TokenStatsRow> - )} - </> + </DataAndQuestionButton> + </SwitchLabel> + </SwitchHolder> + {switches.showFanout && ( + <TokenStatsRow> + <SecondaryButton + style={{ + marginTop: '12px', + marginBottom: '0px', + }} + disabled={nftFanInputs.length === 0} + onClick={createNftMintInputs} + > + Create NFT Mint Inputs + </SecondaryButton> + <ButtonDisabledMsg> + {nftFanInputs.length === 0 + ? 'No token utxos exist with qty !== 1' + : ''} + </ButtonDisabledMsg> + </TokenStatsRow> )} - {!isNftParent && !isNftChild && ( - <> - <SwitchHolder> - <Switch - name="Toggle Burn" - on="🔥" - off="🔥" - checked={switches.showBurn} - handleToggle={() => - // We turn everything else off, whether we are turning this one on or off - setSwitches({ - ...switchesOff, - showBurn: - !switches.showBurn, - }) + <SwitchHolder> + <Switch + name="Toggle Mint NFT" + checked={switches.showMintNft} + disabled={ + nftChildGenesisInput.length === 0 + } + handleToggle={() => + // We turn everything else off, whether we are turning this one on or off + setSwitches({ + ...switchesOff, + showMintNft: + !switches.showMintNft, + }) + } + /> + <SwitchLabel> + <DataAndQuestionButton> + Mint NFT{' '} + {availableNftInputs === 0 ? ( + <ButtonDisabledSpan> + (no NFT mint inputs) + </ButtonDisabledSpan> + ) : ( + <p> + ({availableNftInputs}{' '} + input + {availableNftInputs > 1 + ? 's' + : ''}{' '} + available) + </p> + )} + <IconButton + name={`Click for more info about minting an NFT`} + icon={<QuestionIcon />} + onClick={() => + setShowMintNftInfo(true) } /> - <SwitchLabel> - Burn {tokenTicker} - </SwitchLabel> - </SwitchHolder> - {switches.showBurn && ( - <TokenStatsRow> - <InputFlex> - <SendTokenInput - name="burnAmount" - value={ - formData.burnAmount - } - error={ - burnTokenAmountError - } - placeholder="Burn Amount" - decimals={decimals} - handleInput={ - handleEtokenBurnAmountChange - } - handleOnMax={onMaxBurn} - /> - - <SecondaryButton - onClick={ - handleBurnAmountInput - } - disabled={ - burnTokenAmountError || - formData.burnAmount === - '' - } - > - Burn {tokenTicker} - </SecondaryButton> - </InputFlex> - </TokenStatsRow> - )} - </> + </DataAndQuestionButton> + </SwitchLabel> + </SwitchHolder> + {switches.showMintNft && ( + <CreateTokenForm + nftChildGenesisInput={ + nftChildGenesisInput + } + /> )} - {mintBatons.length > 0 && ( - <SwitchHolder> - <Switch - name="Toggle Mint" - on="⚗️" - off="⚗️" - checked={switches.showMint} - handleToggle={() => - // We turn everything else off, whether we are turning this one on or off - setSwitches({ - ...switchesOff, - showMint: - !switches.showMint, - }) - } - /> - <SwitchLabel>Mint</SwitchLabel> - </SwitchHolder> + </> + )} + {!isNftChild && ( + <> + <SwitchHolder> + <Switch + name="Toggle Airdrop" + on="🪂" + off="🪂" + checked={switches.showAirdrop} + handleToggle={() => + // We turn everything else off, whether we are turning this one on or off + setSwitches({ + ...switchesOff, + showAirdrop: + !switches.showAirdrop, + }) + } + /> + <SwitchLabel> + Airdrop XEC to {tokenTicker} holders + </SwitchLabel> + </SwitchHolder> + {switches.showAirdrop && ( + <TokenStatsRow> + <Link + style={{ width: '100%' }} + to="/airdrop" + state={{ + airdropEtokenId: tokenId, + }} + > + <SecondaryButton + style={{ + marginTop: '12px', + }} + > + Airdrop Calculator + </SecondaryButton> + </Link> + </TokenStatsRow> )} - {switches.showMint && ( + </> + )} + {!isNftParent && !isNftChild && ( + <> + <SwitchHolder> + <Switch + name="Toggle Burn" + on="🔥" + off="🔥" + checked={switches.showBurn} + handleToggle={() => + // We turn everything else off, whether we are turning this one on or off + setSwitches({ + ...switchesOff, + showBurn: !switches.showBurn, + }) + } + /> + <SwitchLabel> + Burn {tokenTicker} + </SwitchLabel> + </SwitchHolder> + {switches.showBurn && ( <TokenStatsRow> <InputFlex> <SendTokenInput - name="mintAmount" - type="number" - value={formData.mintAmount} - error={mintAmountError} - placeholder="Mint Amount" + name="burnAmount" + value={formData.burnAmount} + error={burnTokenAmountError} + placeholder="Burn Amount" decimals={decimals} handleInput={ - handleMintAmountChange + handleEtokenBurnAmountChange } - handleOnMax={onMaxMint} + handleOnMax={onMaxBurn} /> <SecondaryButton - onClick={handleMint} + onClick={handleBurnAmountInput} disabled={ - mintAmountError || - formData.mintAmount === '' + burnTokenAmountError || + formData.burnAmount === '' } > - Mint {tokenTicker} + Burn {tokenTicker} </SecondaryButton> </InputFlex> </TokenStatsRow> )} - </SendTokenForm> + </> )} - </> - )} + {mintBatons.length > 0 && ( + <SwitchHolder> + <Switch + name="Toggle Mint" + on="⚗️" + off="⚗️" + checked={switches.showMint} + handleToggle={() => + // We turn everything else off, whether we are turning this one on or off + setSwitches({ + ...switchesOff, + showMint: !switches.showMint, + }) + } + /> + <SwitchLabel>Mint</SwitchLabel> + </SwitchHolder> + )} + {switches.showMint && ( + <TokenStatsRow> + <InputFlex> + <SendTokenInput + name="mintAmount" + type="number" + value={formData.mintAmount} + error={mintAmountError} + placeholder="Mint Amount" + decimals={decimals} + handleInput={handleMintAmountChange} + handleOnMax={onMaxMint} + /> + + <SecondaryButton + onClick={handleMint} + disabled={ + mintAmountError || + formData.mintAmount === '' + } + > + Mint {tokenTicker} + </SecondaryButton> + </InputFlex> + </TokenStatsRow> + )} + </SendTokenForm> + </> + )} + {tokenConfig.blacklist.includes(tokenId) ? ( + <Alert>Cashtab does not support trading this token.</Alert> + ) : ( + <> + {isSupportedToken && + renderedTokenType === 'SLP' && + hasActiveOffers !== null && + !agoraQueryError && ( + <> + <SubHeading>Order Book</SubHeading> + {pk !== false && hasActiveOffers && ( + <OrderBook + key={tokenId} + tokenId={tokenId} + cachedTokenInfo={cashtabCache.tokens.get( + tokenId, + )} + settings={settings} + userLocale={userLocale} + fiatPrice={fiatPrice} + activePk={pk} + wallet={wallet} + ecc={ecc} + chronik={chronik} + agora={agora} + chaintipBlockheight={ + chaintipBlockheight + } + /> + )} + {!hasActiveOffers && ( + <Info>No active offers for this token</Info> + )} + </> + )} + </> + )} + + {isSupportedToken && agoraQueryError && ( + <ChronikErrorAlert> + Error querying agora offers. Please try again later. + </ChronikErrorAlert> + )} </> + ) : chronikQueryError ? ( + <TokenScreenWrapper title="Chronik Query Error"> + <ChronikErrorAlert> + Error querying token info. Please try again later. + </ChronikErrorAlert> + {typeof tokenBalance === 'undefined' && ( + <Info>You do not hold this token.</Info> + )} + </TokenScreenWrapper> + ) : ( + <LoadingCtn title="Loading token"> + <Spinner /> + </LoadingCtn> ); }; diff --git a/cashtab/src/components/Etokens/Token/styled.js b/cashtab/src/components/Etokens/Token/styled.js --- a/cashtab/src/components/Etokens/Token/styled.js +++ b/cashtab/src/components/Etokens/Token/styled.js @@ -4,6 +4,15 @@ import styled from 'styled-components'; +export const TokenScreenWrapper = styled.div` + color: ${props => props.theme.contrast}; + width: 100%; + h2 { + margin: 0 0 20px; + margin-top: 10px; + } +`; + export const InfoModalParagraph = styled.p` color: ${props => props.theme.contrast}; text-align: left; diff --git a/cashtab/src/components/Etokens/__tests__/Token.test.js b/cashtab/src/components/Etokens/__tests__/Token.test.js --- a/cashtab/src/components/Etokens/__tests__/Token.test.js +++ b/cashtab/src/components/Etokens/__tests__/Token.test.js @@ -21,8 +21,15 @@ import { slp1FixedBear, slp1FixedCachet, + MOCK_TOKEN_UTXO, } from 'components/Etokens/fixtures/mocks'; import { Ecc, initWasm } from 'ecash-lib'; +import { + agoraOfferCachetAlphaOne, + scamCacheMocks, + scamAgoraOffer, +} from 'components/Agora/fixtures/mocks'; +import { MockAgora } from '../../../../../modules/mock-chronik-client'; // https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function Object.defineProperty(window, 'matchMedia', { @@ -168,6 +175,333 @@ screen.getByRole('button', { name: /List BearNip/ }), ).toHaveProperty('disabled', true); }); + it('For an uncached token with no balance, we show a spinner while loading the token info, then show an info screen and open agora offers', async () => { + // Set mock tokeninfo call + const CACHET_TOKENID = slp1FixedCachet.tokenId; + mockedChronik.setMock('token', { + input: CACHET_TOKENID, + output: slp1FixedCachet.token, + }); + mockedChronik.setMock('tx', { + input: CACHET_TOKENID, + output: slp1FixedCachet.tx, + }); + mockedChronik.setTokenId(CACHET_TOKENID); + mockedChronik.setUtxosByTokenId(CACHET_TOKENID, { + tokenId: slp1FixedCachet.tokenId, + utxos: slp1FixedCachet.utxos, + }); + + // Need to mock agora API endpoints + const mockedAgora = new MockAgora(); + + // then mock for each one agora.activeOffersByTokenId(offeredTokenId) + mockedAgora.setActiveOffersByTokenId(CACHET_TOKENID, [ + agoraOfferCachetAlphaOne, + ]); + + render( + <CashtabTestWrapper + chronik={mockedChronik} + agora={mockedAgora} + ecc={ecc} + route={`/token/${CACHET_TOKENID}`} + />, + ); + + // Wait for Cashtab wallet info to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + + // We see a spinner while token info is loading + expect(screen.getByTitle('Loading token')).toBeInTheDocument(); + + // Cashtab pings chronik to build token cache info and displays token summary table + expect((await screen.findAllByText(/CACHET/))[0]).toBeInTheDocument(); + + // We see the token supply + expect(screen.getByText('Supply:')).toBeInTheDocument(); + expect( + screen.getByText('2,999,998,798,000,000,000 (fixed)'), + ).toBeInTheDocument(); + + // We see a notice that we do not hold this token + expect( + screen.getByText('You do not hold this token.'), + ).toBeInTheDocument(); + + // We do not see token actions + expect(screen.queryByTitle('Token Actions')).not.toBeInTheDocument(); + + // Even if we do not hold the token, we see active offer information + expect(screen.getByText('Order Book')).toBeInTheDocument(); + }); + it('If there is an error fetching agora offers for an uncached unheld SLP1 token, we see agora query error', async () => { + // Set mock tokeninfo call + const CACHET_TOKENID = slp1FixedCachet.tokenId; + mockedChronik.setMock('token', { + input: CACHET_TOKENID, + output: slp1FixedCachet.token, + }); + mockedChronik.setMock('tx', { + input: CACHET_TOKENID, + output: slp1FixedCachet.tx, + }); + mockedChronik.setTokenId(CACHET_TOKENID); + mockedChronik.setUtxosByTokenId(CACHET_TOKENID, { + tokenId: slp1FixedCachet.tokenId, + utxos: slp1FixedCachet.utxos, + }); + + // Need to mock agora API endpoints + const mockedAgora = new MockAgora(); + + // mock an error at await agora.offeredFungibleTokenIds(); + mockedAgora.setActiveOffersByTokenId( + CACHET_TOKENID, + new Error('some agora query error'), + ); + + render( + <CashtabTestWrapper + chronik={mockedChronik} + agora={mockedAgora} + ecc={ecc} + route={`/token/${CACHET_TOKENID}`} + />, + ); + + // Wait for Cashtab wallet info to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + + // We see a spinner while token info is loading + expect(screen.getByTitle('Loading token')).toBeInTheDocument(); + + // Cashtab pings chronik to build token cache info and displays token summary table + expect((await screen.findAllByText(/CACHET/))[0]).toBeInTheDocument(); + + // We see the token supply + expect(screen.getByText('Supply:')).toBeInTheDocument(); + expect( + screen.getByText('2,999,998,798,000,000,000 (fixed)'), + ).toBeInTheDocument(); + + // We see a notice that we do not hold this token + expect( + screen.getByText('You do not hold this token.'), + ).toBeInTheDocument(); + + // We do not see token actions + expect(screen.queryByTitle('Token Actions')).not.toBeInTheDocument(); + + // If there is an error querying agora offers (e.g. the chronik server is not indexed for this) + // We see expected explanation instead of the order book + expect(screen.queryByText('Order Book')).not.toBeInTheDocument(); + + expect( + screen.getByText( + 'Error querying agora offers. Please try again later.', + ), + ).toBeInTheDocument(); + }); + it('If an SLP1 token has no offers, we show a notice', async () => { + // Set mock tokeninfo call + const CACHET_TOKENID = slp1FixedCachet.tokenId; + mockedChronik.setMock('token', { + input: CACHET_TOKENID, + output: slp1FixedCachet.token, + }); + mockedChronik.setMock('tx', { + input: CACHET_TOKENID, + output: slp1FixedCachet.tx, + }); + mockedChronik.setTokenId(CACHET_TOKENID); + mockedChronik.setUtxosByTokenId(CACHET_TOKENID, { + tokenId: slp1FixedCachet.tokenId, + utxos: slp1FixedCachet.utxos, + }); + + // Need to mock agora API endpoints + const mockedAgora = new MockAgora(); + + // then mock for each one agora.activeOffersByTokenId(offeredTokenId) + mockedAgora.setActiveOffersByTokenId(CACHET_TOKENID, []); + + render( + <CashtabTestWrapper + chronik={mockedChronik} + agora={mockedAgora} + ecc={ecc} + route={`/token/${CACHET_TOKENID}`} + />, + ); + + // Wait for Cashtab wallet info to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + + // We see a spinner while token info is loading + expect(screen.getByTitle('Loading token')).toBeInTheDocument(); + + // Cashtab pings chronik to build token cache info and displays token summary table + expect((await screen.findAllByText(/CACHET/))[0]).toBeInTheDocument(); + + // We see the token supply + expect(screen.getByText('Supply:')).toBeInTheDocument(); + expect( + screen.getByText('2,999,998,798,000,000,000 (fixed)'), + ).toBeInTheDocument(); + + // We see a notice that we do not hold this token + expect( + screen.getByText('You do not hold this token.'), + ).toBeInTheDocument(); + + // We do not see token actions + expect(screen.queryByTitle('Token Actions')).not.toBeInTheDocument(); + + // We see the Order Book subheader + expect(screen.getByText('Order Book')).toBeInTheDocument(); + + expect( + screen.getByText('No active offers for this token'), + ).toBeInTheDocument(); + }); + it('A blacklisted token is flagged as such, and the Orderbook is not rendered', async () => { + // Set mock tokeninfo call + const SCAM_TOKEN_ID = scamCacheMocks.token.tokenId; + mockedChronik.setMock('token', { + input: SCAM_TOKEN_ID, + output: scamCacheMocks.token, + }); + mockedChronik.setMock('tx', { + input: SCAM_TOKEN_ID, + output: scamCacheMocks.tx, + }); + mockedChronik.setTokenId(SCAM_TOKEN_ID); + + // These are used to calc the total supply of the scam token + const SCAM_SUPPLY = '1000'; + mockedChronik.setUtxosByTokenId(SCAM_TOKEN_ID, { + tokenId: SCAM_TOKEN_ID.tokenId, + utxos: [ + { + ...MOCK_TOKEN_UTXO, + token: { + ...MOCK_TOKEN_UTXO.token, + tokenId: SCAM_TOKEN_ID, + amount: SCAM_SUPPLY, + }, + }, + ], + }); + + // Need to mock agora API endpoints + const mockedAgora = new MockAgora(); + + // then mock for each one agora.activeOffersByTokenId(offeredTokenId) + mockedAgora.setActiveOffersByTokenId(SCAM_TOKEN_ID, [scamAgoraOffer]); + + render( + <CashtabTestWrapper + chronik={mockedChronik} + agora={mockedAgora} + ecc={ecc} + route={`/token/${SCAM_TOKEN_ID}`} + />, + ); + + // Wait for Cashtab wallet info to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + + // We see a spinner while token info is loading + expect(screen.getByTitle('Loading token')).toBeInTheDocument(); + + // Cashtab pings chronik to build token cache info and displays token summary table + expect((await screen.findAllByText(/BUX/))[0]).toBeInTheDocument(); + + // We see the token supply + expect(screen.getByText('Supply:')).toBeInTheDocument(); + expect(screen.getByText(`1,000 (fixed)`)).toBeInTheDocument(); + + // We see a notice that we do not hold this token + expect( + screen.getByText('You do not hold this token.'), + ).toBeInTheDocument(); + + // We do not see token actions because we have no balance + expect(screen.queryByTitle('Token Actions')).not.toBeInTheDocument(); + + // We do not see an order book for a sccam token, even though there is an active offer + expect(screen.queryByText('Order Book')).not.toBeInTheDocument(); + + // We see the blacklisted notice + expect( + screen.getByText('Cashtab does not support trading this token.'), + ).toBeInTheDocument(); + }); + it('For an uncached token with no balance, we show a chronik query error if we are unable to fetch the token info', async () => { + // Set mock tokeninfo call + const CACHET_TOKENID = slp1FixedCachet.tokenId; + mockedChronik.setMock('token', { + input: CACHET_TOKENID, + output: new Error('some error'), + }); + mockedChronik.setMock('tx', { + input: CACHET_TOKENID, + output: new Error('some error'), + }); + mockedChronik.setTokenId(CACHET_TOKENID); + mockedChronik.setUtxosByTokenId(CACHET_TOKENID, { + tokenId: slp1FixedCachet.tokenId, + utxos: new Error('some error'), + }); + + render( + <CashtabTestWrapper + chronik={mockedChronik} + ecc={ecc} + route={`/token/${CACHET_TOKENID}`} + />, + ); + + // Wait for Cashtab wallet info to load + await waitFor(() => + expect( + screen.queryByTitle('Cashtab Loading'), + ).not.toBeInTheDocument(), + ); + + // We see a spinner while token info is loading + expect(screen.getByTitle('Loading token')).toBeInTheDocument(); + + // We see an error info box on chronik error + expect( + await screen.findByTitle('Chronik Query Error'), + ).toBeInTheDocument(); + + // We see a notice that we do not hold this token + expect( + screen.getByText('You do not hold this token.'), + ).toBeInTheDocument(); + + // We do not see token actions + expect(screen.queryByTitle('Token Actions')).not.toBeInTheDocument(); + }); it('Accepts a valid ecash: prefixed address', async () => { render( <CashtabTestWrapper diff --git a/cashtab/src/components/Etokens/fixtures/mocks.js b/cashtab/src/components/Etokens/fixtures/mocks.js --- a/cashtab/src/components/Etokens/fixtures/mocks.js +++ b/cashtab/src/components/Etokens/fixtures/mocks.js @@ -13,7 +13,7 @@ */ // Used only for circulating suppply calculation -const MOCK_TOKEN_UTXO = { +export const MOCK_TOKEN_UTXO = { token: { tokenId: '20a0b9337a78603c6681ed2bc541593375535dcd9979196620ce71f233f2f6f8', diff --git a/cashtab/src/components/Nfts/index.js b/cashtab/src/components/Nfts/index.js --- a/cashtab/src/components/Nfts/index.js +++ b/cashtab/src/components/Nfts/index.js @@ -4,7 +4,11 @@ import React, { useState, useEffect } from 'react'; import { WalletContext } from 'wallet/context'; -import { LoadingCtn, SwitchLabel } from 'components/Common/Atoms'; +import { + LoadingCtn, + SwitchLabel, + ChronikErrorAlert, +} from 'components/Common/Atoms'; import Spinner from 'components/Common/Spinner'; import { getTokenGenesisInfo } from 'chronik'; import { @@ -19,7 +23,6 @@ OfferCol, OfferRow, OfferIcon, - ChronikErrorAlert, } from './styled'; import { NftTokenIdAndCopyIcon, diff --git a/cashtab/src/components/Nfts/styled.js b/cashtab/src/components/Nfts/styled.js --- a/cashtab/src/components/Nfts/styled.js +++ b/cashtab/src/components/Nfts/styled.js @@ -4,7 +4,6 @@ import styled from 'styled-components'; import { token as tokenConfig } from 'config/token'; -import { Alert } from 'components/Common/Atoms'; export const NftsCtn = styled.div` color: ${props => props.theme.contrast}; @@ -60,6 +59,3 @@ display: flex; flex-direction: column; `; -export const ChronikErrorAlert = styled(Alert)` - margin-top: 12px; -`;