Page MenuHomePhabricator

D17064.id50626.diff
No OneTemporary

D17064.id50626.diff

diff --git a/cashtab/src/components/Agora/OrderBook/__tests__/index.test.js b/cashtab/src/components/Agora/OrderBook/__tests__/index.test.js
new file mode 100644
--- /dev/null
+++ b/cashtab/src/components/Agora/OrderBook/__tests__/index.test.js
@@ -0,0 +1,600 @@
+// Copyright (c) 2024 The Bitcoin developers
+// Distributed under the MIT software license, see the accompanying
+// file COPYING or http://www.opensource.org/licenses/mit-license.php.
+
+import React from 'react';
+import { ThemeProvider } from 'styled-components';
+import { theme } from 'assets/styles/theme';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import 'fake-indexeddb/auto';
+import {
+ agoraPartialAlphaWallet,
+ agoraOfferCachetAlphaOne,
+ agoraOfferCachetAlphaTwo,
+ agoraOfferCachetBetaOne,
+ cachetCacheMocks,
+ agoraPartialAlphaKeypair,
+ CachedCachet,
+ SettingsUsd,
+} from 'components/Agora/fixtures/mocks';
+import { Ecc, initWasm } from 'ecash-lib';
+import {
+ MockAgora,
+ MockChronikClient,
+} from '../../../../../../modules/mock-chronik-client';
+import Orderbook from 'components/Agora/OrderBook';
+import { Bounce } from 'react-toastify';
+import { CashtabNotification } from 'components/App/styles';
+
+// https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // Deprecated
+ removeListener: jest.fn(), // Deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+});
+
+// https://stackoverflow.com/questions/64813447/cannot-read-property-addlistener-of-undefined-react-testing-library
+window.matchMedia = query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // deprecated
+ removeListener: jest.fn(), // deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+});
+
+/**
+ * Test expected behavior of the OrderBook component
+ * OrderBook is a self-contained component that presents Agora Partial offers to the user
+ * The logic for fetching the offers, updating the offers on buys and cancels, and buying
+ * and canceling offers is all in the component
+ *
+ * Keeping the logic in the component makes it easy to load many OrderBooks in parallel.
+ *
+ * TODO add websocket support for faster self-updating.
+ *
+ * We accept a "noIcon" param for a compact version of the OrderBook suitable for appearing
+ * on a token information page that already displays the icon
+ */
+describe('<OrderBook />', () => {
+ let ecc;
+ const CACHET_TOKEN_ID = cachetCacheMocks.token.tokenId;
+ beforeAll(async () => {
+ await initWasm();
+ ecc = new Ecc();
+ });
+
+ let mockedChronik;
+ beforeEach(async () => {
+ mockedChronik = new MockChronikClient();
+ });
+ afterEach(async () => {
+ jest.clearAllMocks();
+ });
+ it('We render expected msg if no agora partial listings are found for this token', async () => {
+ // Need to mock agora API endpoints
+ const mockedAgora = new MockAgora();
+
+ // No active offers
+ mockedAgora.setActiveOffersByTokenId(CACHET_TOKEN_ID, []);
+
+ render(
+ <ThemeProvider theme={theme}>
+ <Orderbook
+ tokenId={CACHET_TOKEN_ID}
+ cachedTokenInfo={CachedCachet}
+ settings={SettingsUsd}
+ userLocale={'en-US'}
+ fiatPrice={0.000033}
+ activePk={agoraPartialAlphaKeypair.pk}
+ wallet={agoraPartialAlphaWallet}
+ ecc={ecc}
+ chronik={mockedChronik}
+ agora={mockedAgora}
+ chaintipBlockheight={800000}
+ />
+ </ThemeProvider>,
+ );
+
+ // We see a spinner while activeOffers load
+ expect(screen.getByTitle('Loading')).toBeInTheDocument();
+
+ // After offers load, we see a notice that there are no active offers
+ expect(
+ await screen.findByText('No active offers for this token'),
+ ).toBeInTheDocument();
+ });
+ it('An error notice is rendered if there is some error in querying listings', async () => {
+ // Need to mock agora API endpoints
+ const mockedAgora = new MockAgora();
+
+ // No active offers
+ mockedAgora.setActiveOffersByTokenId(
+ CACHET_TOKEN_ID,
+ new Error('some error querying offers'),
+ );
+
+ render(
+ <ThemeProvider theme={theme}>
+ <Orderbook
+ tokenId={CACHET_TOKEN_ID}
+ cachedTokenInfo={CachedCachet}
+ settings={SettingsUsd}
+ userLocale={'en-US'}
+ fiatPrice={0.000033}
+ activePk={agoraPartialAlphaKeypair.pk}
+ wallet={agoraPartialAlphaWallet}
+ ecc={ecc}
+ chronik={mockedChronik}
+ agora={mockedAgora}
+ chaintipBlockheight={800000}
+ />
+ ,
+ </ThemeProvider>,
+ );
+
+ // We see a spinner while activeOffers load
+ expect(screen.getByTitle('Loading')).toBeInTheDocument();
+
+ // After offers load, we see a notice that there are no active offers
+ expect(
+ await screen.findByText(
+ 'Error querying agora for active offers. Try again later.',
+ ),
+ ).toBeInTheDocument();
+ });
+ it('We can see a rendered offer', async () => {
+ // Need to mock agora API endpoints
+ const mockedAgora = new MockAgora();
+
+ // then mock for each one agora.activeOffersByTokenId(offeredTokenId)
+ mockedAgora.setActiveOffersByTokenId(CACHET_TOKEN_ID, [
+ agoraOfferCachetAlphaOne,
+ ]);
+
+ render(
+ <ThemeProvider theme={theme}>
+ <Orderbook
+ tokenId={CACHET_TOKEN_ID}
+ cachedTokenInfo={CachedCachet}
+ settings={SettingsUsd}
+ userLocale={'en-US'}
+ fiatPrice={0.000033}
+ activePk={agoraPartialAlphaKeypair.pk}
+ wallet={agoraPartialAlphaWallet}
+ ecc={ecc}
+ chronik={mockedChronik}
+ agora={mockedAgora}
+ chaintipBlockheight={800000}
+ />
+ ,
+ </ThemeProvider>,
+ );
+
+ // We see a spinner while activeOffers load
+ expect(screen.getByTitle('Loading')).toBeInTheDocument();
+
+ // After loading, we see the token name and ticker above its PartialOffer
+ expect(await screen.findByText('Cachet (CACHET)')).toBeInTheDocument();
+
+ // We see the token icon
+ expect(screen.getByTitle(CACHET_TOKEN_ID)).toBeInTheDocument();
+
+ // We see the spot price on the depth bar
+ expect(screen.getByText('$0.33 USD')).toBeInTheDocument();
+
+ // The min offer amount is selected by default
+ expect(screen.getByText('.10')).toBeInTheDocument();
+ // We see the formatted price in XEC
+ expect(await screen.findByText('1k XEC')).toBeInTheDocument();
+ // We see the price in fiat
+ expect(screen.getByText('$0.033 USD')).toBeInTheDocument();
+
+ // Query the slider by its role and aria-labelledby attribute
+ const slider = screen.getByRole('slider');
+
+ // We see a slider
+ expect(slider).toBeInTheDocument();
+
+ // We can move the slider and see the price of different quantities
+ fireEvent.change(slider, { target: { value: 170 } });
+ expect(screen.getByText('1.70')).toBeInTheDocument();
+ expect(await screen.findByText('17k XEC')).toBeInTheDocument();
+ expect(screen.getByText('$0.56 USD')).toBeInTheDocument();
+
+ // Slider action is for informational purposes only here, though, because
+ // this wallet created this offer (determined by public key)
+
+ // Because this offer was created by this wallet, we have the option to cancel it
+ expect(
+ await screen.findByRole('button', { name: 'Cancel your offer' }),
+ ).toBeInTheDocument();
+ });
+ it('We can see a rendered offer in an OrderBook with noIcon', async () => {
+ // Need to mock agora API endpoints
+ const mockedAgora = new MockAgora();
+
+ // then mock for each one agora.activeOffersByTokenId(offeredTokenId)
+ mockedAgora.setActiveOffersByTokenId(CACHET_TOKEN_ID, [
+ agoraOfferCachetAlphaOne,
+ ]);
+
+ render(
+ <ThemeProvider theme={theme}>
+ <Orderbook
+ tokenId={CACHET_TOKEN_ID}
+ cachedTokenInfo={CachedCachet}
+ settings={SettingsUsd}
+ userLocale={'en-US'}
+ fiatPrice={0.000033}
+ activePk={agoraPartialAlphaKeypair.pk}
+ wallet={agoraPartialAlphaWallet}
+ ecc={ecc}
+ chronik={mockedChronik}
+ agora={mockedAgora}
+ chaintipBlockheight={800000}
+ noIcon
+ />
+ ,
+ </ThemeProvider>,
+ );
+
+ // We see a spinner while activeOffers load
+ expect(screen.getByTitle('Loading')).toBeInTheDocument();
+
+ // We see the spot price on the depth bar
+ expect(await screen.findByText('$0.33 USD')).toBeInTheDocument();
+
+ // After loading, we DO NOT see the token name and ticker above its PartialOffer
+ expect(screen.queryByText('Cachet (CACHET)')).not.toBeInTheDocument();
+
+ // We DO NOT see the token icon
+ expect(screen.queryByTitle(CACHET_TOKEN_ID)).not.toBeInTheDocument();
+
+ // The min offer amount is selected by default
+ expect(screen.getByText('.10')).toBeInTheDocument();
+ // We see the formatted price in XEC
+ expect(await screen.findByText('1k XEC')).toBeInTheDocument();
+ // We see the price in fiat
+ expect(screen.getByText('$0.033 USD')).toBeInTheDocument();
+
+ // Query the slider by its role and aria-labelledby attribute
+ const slider = screen.getByRole('slider');
+
+ // We see a slider
+ expect(slider).toBeInTheDocument();
+
+ // We can move the slider and see the price of different quantities
+ fireEvent.change(slider, { target: { value: 170 } });
+ expect(screen.getByText('1.70')).toBeInTheDocument();
+ expect(await screen.findByText('17k XEC')).toBeInTheDocument();
+ expect(screen.getByText('$0.56 USD')).toBeInTheDocument();
+
+ // Slider action is for informational purposes only here, though, because
+ // this wallet created this offer (determined by public key)
+
+ // Because this offer was created by this wallet, we have the option to cancel it
+ expect(
+ await screen.findByRole('button', { name: 'Cancel your offer' }),
+ ).toBeInTheDocument();
+ });
+ it('We can see multiple offers, some we made, others we did not, and we can cancel an offer', async () => {
+ // Need to mock agora API endpoints
+ const mockedAgora = new MockAgora();
+
+ // then mock for each one agora.activeOffersByTokenId(offeredTokenId)
+ mockedAgora.setActiveOffersByTokenId(CACHET_TOKEN_ID, [
+ agoraOfferCachetAlphaOne,
+ agoraOfferCachetAlphaTwo,
+ agoraOfferCachetBetaOne,
+ ]);
+
+ // Set mocks for tx that cancels a listing
+ const cancelHex =
+ '0200000002f7bb552354b6f5076eb2664a8bcbbedc87b42f2ebfcb1480ee0a9141bbae63590000000064414e90dfcdd1508f599267d5b761db8268c164567032f6eb597677d010df4e67eb61e29721535f92070d3c77d7679d78a209122aabec6c7f8d536db072b7dda28241210233f09cd4dc3381162f09975f90866f085350a5ec890d7fba5f6739c9c0ac2afdffffffffbfd08cec4d74b7820cea750b36a0a69d88b6cec3c084caf29e9b866cd8999f6d01000000fdab010441475230075041525449414c4162790797e5a77ccb0326f5e85ad2ec334b17616a636bad4d21a9fa8ec73e6e249443ef7f598a513ee6023bf0f4090300e3f1f37e96c5ea39fe15db0f2f3a56b941004d58014c766a04534c500001010453454e4420aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb10800000000000000000001db4603000000000079150000000000008ec420000000000008b7023e0233f09cd4dc3381162f09975f90866f085350a5ec890d7fba5f6739c9c0ac2afd08b0caff7f00000000ab7b63817b6ea26976038ec420a2697603db46039700887d94527901377f75789263587e7803db4603965880bc007e7e68587e527903db4603965880bc007e7e825980bc7c7e01007e7b027815930279159657807e041976a914707501557f77a97e0288ac7e7e6b7d02220258800317a9147e024c7672587d807e7e7e01ab7e537901257f7702d6007f5c7f7701207f547f750408b7023e886b7ea97e01877e7c92647500687b8292697e6c6c7b7eaa88520144807c7ea86f7bbb7501c17e7c677501557f7768ad075041525449414c88044147523087ffffffff030000000000000000376a04534c500001010453454e4420aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb108000000000000271022020000000000001976a91403b830e4b9dce347f3495431e1f9d1005f4b420488acaf650600000000001976a91403b830e4b9dce347f3495431e1f9d1005f4b420488ac00000000';
+ const cancelTxid =
+ '256ffa0a5e18f7c546673ff6c49fb4d483fe2cbae3b1269bc1000c4c6d950fa9';
+
+ mockedChronik.setMock('broadcastTx', {
+ input: cancelHex,
+ output: { txid: cancelTxid },
+ });
+
+ // Note we must include CashtabNotification to test toastify notification
+ render(
+ <ThemeProvider theme={theme}>
+ <CashtabNotification
+ position="top-right"
+ autoClose={5000}
+ hideProgressBar={false}
+ newestOnTop
+ closeOnClick
+ rtl={false}
+ pauseOnFocusLoss
+ draggable
+ pauseOnHover
+ theme="light"
+ transition={Bounce}
+ />
+ <Orderbook
+ tokenId={CACHET_TOKEN_ID}
+ cachedTokenInfo={CachedCachet}
+ settings={SettingsUsd}
+ userLocale={'en-US'}
+ fiatPrice={0.00003}
+ activePk={agoraPartialAlphaKeypair.pk}
+ wallet={agoraPartialAlphaWallet}
+ ecc={ecc}
+ chronik={mockedChronik}
+ agora={mockedAgora}
+ chaintipBlockheight={800000}
+ />
+ </ThemeProvider>,
+ );
+
+ // We see a spinner while activeOffers load
+ expect(screen.getByTitle('Loading')).toBeInTheDocument();
+
+ // After loading, we see the token name and ticker above its PartialOffer
+ expect(await screen.findByText('Cachet (CACHET)')).toBeInTheDocument();
+
+ // We see the token icon
+ expect(screen.getByTitle(CACHET_TOKEN_ID)).toBeInTheDocument();
+
+ // We see a spot price for each active offer
+ expect(screen.getByText('$0.30 USD')).toBeInTheDocument();
+ expect(screen.getByText('$0.36 USD')).toBeInTheDocument();
+ expect(screen.getByText('$0.036 USD')).toBeInTheDocument();
+
+ // For tokens with multiple partial offers available, the lowest-priced
+ // offer is selected by default ("spot price")
+ const CACHET_SPOT_MIN_QTY = '.20';
+ const CACHET_SPOT_PRICE_MIN_BUY = '240.64 XEC';
+ const CACHET_SPOT_PRICE_FIAT_MIN_BUY = '$0.0072 USD';
+ // Quantities are not displayed until they load, so we await
+ expect(
+ await screen.findByText(CACHET_SPOT_MIN_QTY),
+ ).toBeInTheDocument();
+ expect(screen.getByText(CACHET_SPOT_PRICE_MIN_BUY)).toBeInTheDocument();
+ expect(
+ screen.getByText(CACHET_SPOT_PRICE_FIAT_MIN_BUY),
+ ).toBeInTheDocument();
+
+ // Because the spot offer was created by this pk, we see a cancel button
+ expect(
+ screen.getByRole('button', { name: 'Cancel your offer' }),
+ ).toBeInTheDocument();
+
+ // If we select the offer created by the Beta wallet, we see a buy button
+ await userEvent.click(screen.getByText('$0.36 USD'));
+
+ // We also see updates to the rendered spot details
+ const UPDATED_CACHET_SPOT_MIN_QTY = '.30';
+ const UPDATED_CACHET_SPOT_PRICE_MIN_BUY = '3.6k XEC';
+ const UPDATED_CACHET_SPOT_PRICE_FIAT_MIN_BUY = '$0.11 USD';
+ expect(
+ screen.getByText(UPDATED_CACHET_SPOT_MIN_QTY),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(UPDATED_CACHET_SPOT_PRICE_MIN_BUY),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(UPDATED_CACHET_SPOT_PRICE_FIAT_MIN_BUY),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole('button', { name: 'Buy Cachet (CACHET)' }),
+ ).toBeInTheDocument();
+
+ // Let's select our other offer
+ await userEvent.click(screen.getByText('$0.30 USD'));
+
+ const OTHER_CACHET_SPOT_MIN_QTY = '.10';
+ const OTHER_CACHET_SPOT_PRICE_MIN_BUY = '1k XEC';
+ const OTHER_CACHET_SPOT_PRICE_FIAT_MIN_BUY = '$0.030 USD';
+ // Quantities are not displayed until they load, so we await
+ expect(
+ await screen.findByText(OTHER_CACHET_SPOT_MIN_QTY),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(OTHER_CACHET_SPOT_PRICE_MIN_BUY),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(OTHER_CACHET_SPOT_PRICE_FIAT_MIN_BUY),
+ ).toBeInTheDocument();
+
+ // Let's cancel it (a little high vs spot)
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Cancel your offer' }),
+ );
+
+ // We see a confirmation modal
+ expect(
+ screen.getByText(
+ 'Cancel your offer to sell 100.00 Cachet (CACHET) for 1,000.96 XEC ($0.030 USD)?',
+ ),
+ ).toBeInTheDocument();
+
+ // We cancel
+ await userEvent.click(screen.getByText('OK'));
+
+ // Notification on successful cancel
+ expect(await screen.findByText(`Canceled listing`)).toBeInTheDocument();
+
+ // Note we can't test that offers are refreshed as we cannot dynamically adjust chronik mocks
+ // Would need regtest integration to do this
+ });
+ it('We can buy an offer', async () => {
+ // Need to mock agora API endpoints
+ const mockedAgora = new MockAgora();
+
+ // then mock for each one agora.activeOffersByTokenId(offeredTokenId)
+ mockedAgora.setActiveOffersByTokenId(CACHET_TOKEN_ID, [
+ agoraOfferCachetAlphaOne,
+ agoraOfferCachetAlphaTwo,
+ agoraOfferCachetBetaOne,
+ ]);
+
+ // Set mocks for tx that buys a listing
+ const buyHex =
+ '02000000023f091a214fdf5ff45e1cae5f7830800a73740cbd3b752f3694090cc962b59c8101000000fd47030441475230075041525449414c21023c72addb4fdf09af94f0c94d7fe92a386a7e70cf8a1d85916386bb2535c7b1b14090e96508f39a758f637806b85a0a876c31291e4d3d7424138de28d669147a6c913a664f37444b7644ad57da91d725b3bbd731858de837d63484be6c834d391ce4422020000000000001976a91403b830e4b9dce347f3495431e1f9d1005f4b420488ac9de20000000000001976a91403b830e4b9dce347f3495431e1f9d1005f4b420488ac4d2f013f091a214fdf5ff45e1cae5f7830800a73740cbd3b752f3694090cc962b59c8101000000d67b63817b6ea269760384c420a26976039e17019700887d94527901377f75789263587e78039e1701965880bc007e7e68587e5279039e1701965880bc007e7e825980bc7c7e01007e7b02f6059302f7059657807e041976a914707501557f77a97e0288ac7e7e6b7d02220258800317a9147e024c7672587d807e7e7e01ab7e537901257f7702d6007f5c7f7701207f547f7504ce731f40886b7ea97e01877e7c92647500687b8292697e6c6c7b7eaa88520144807c7ea86f7bbb7501c17e7c677501557f7768ad075041525449414c880441475230872202000000000000ffffffff10a8e6470b2c60bde9' +
+ '593640fef02460656cf16385493523091338366a7688e9ce731f40c10000000384c420514d58014c766a04534c500001010453454e4420aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb108000000000000000000019e17010000000000f70500000000000084c4200000000000ce731f40021e75febb8ae57a8805e80df93732ab7d5d8606377cb30c0f02444809cc085f3908a0a3ff7f00000000ab7b63817b6ea269760384c420a26976039e17019700887d94527901377f75789263587e78039e1701965880bc007e7e68587e5279039e1701965880bc007e7e825980bc7c7e01007e7b02f6059302f7059657807e041976a914707501557f77a97e0288ac7e7e6b7d02220258800317a9147e024c7672587d807e7e7e01ab7e537901257f7702d6007f5c7f7701207f547f7504ce731f40886b7ea97e01877e7c92647500687b8292697e6c6c7b7eaa88520144807c7ea86f7bbb7501c17e7c677501557f7768ad075041525449414c88044147523087fffffffff7bb552354b6f5076eb2664a8bcbbedc87b42f2ebfcb1480ee0a9141bbae6359000000006441ed5b343334ab7603062faac5469e7b8b1513cec8e8730c972f4759e4fed0ef9cbd0a50b944d7e8094192ba99fd5eea6e61f568ba12a6b542deca6eea77761d1841210233f09cd4dc3381162f09975f90866f085350a5ec890d7fba5f6739c9c0ac2afdffffffff050000000000000000496a04534c500001010453454e4420aed861a31b96934b88c0252ede135cb9700d7649f69191235087a3030e553cb108000000000000000008000000000000751208000000000000001e007f0500000000001976a914f208ef75eb0dd778ea4540cbd966a830c7b94bb088ac220200000000000017a914211be508fb7608c0a3b3d7a36279894d0450e7378722020000000000001976a91403b830e4b9dce347f3495431e1f9d1005f4b420488ac9de20000000000001976a91403b830e4b9dce347f3495431e1f9d1005f4b420488acce731f40';
+ const buyTxid =
+ 'eb298e786a91676f5b88b45d31d3979d6a8f96771ed99a69f3fa1aa1306238b0';
+
+ mockedChronik.setMock('broadcastTx', {
+ input: buyHex,
+ output: { txid: buyTxid },
+ });
+
+ // Note we must include CashtabNotification to test toastify notification
+ render(
+ <ThemeProvider theme={theme}>
+ <CashtabNotification
+ position="top-right"
+ autoClose={5000}
+ hideProgressBar={false}
+ newestOnTop
+ closeOnClick
+ rtl={false}
+ pauseOnFocusLoss
+ draggable
+ pauseOnHover
+ theme="light"
+ transition={Bounce}
+ />
+ <Orderbook
+ tokenId={CACHET_TOKEN_ID}
+ cachedTokenInfo={CachedCachet}
+ settings={SettingsUsd}
+ userLocale={'en-US'}
+ fiatPrice={0.00003}
+ activePk={agoraPartialAlphaKeypair.pk}
+ wallet={agoraPartialAlphaWallet}
+ ecc={ecc}
+ chronik={mockedChronik}
+ agora={mockedAgora}
+ chaintipBlockheight={800000}
+ />
+ </ThemeProvider>,
+ );
+
+ // We see a spinner while activeOffers load
+ expect(screen.getByTitle('Loading')).toBeInTheDocument();
+
+ // After loading, we see the token name and ticker above its PartialOffer
+ expect(await screen.findByText('Cachet (CACHET)')).toBeInTheDocument();
+
+ // We see the expected spot offer for CACHET
+ const CACHET_SPOT_MIN_QTY = '.20';
+ const CACHET_SPOT_PRICE_MIN_BUY = '240.64 XEC';
+ const CACHET_SPOT_PRICE_FIAT_MIN_BUY = '$0.0072 USD';
+
+ // Quantities are not displayed until they load, so we await
+ expect(
+ await screen.findByText(CACHET_SPOT_MIN_QTY),
+ ).toBeInTheDocument();
+ expect(screen.getByText(CACHET_SPOT_PRICE_MIN_BUY)).toBeInTheDocument();
+ expect(
+ screen.getByText(CACHET_SPOT_PRICE_FIAT_MIN_BUY),
+ ).toBeInTheDocument();
+
+ // If we select the offer created by the Beta wallet, we see a buy button
+ await userEvent.click(screen.getByText('$0.36 USD'));
+
+ // We also see updates to the rendered spot details
+ const UPDATED_CACHET_SPOT_MIN_QTY = '.30';
+ const UPDATED_CACHET_SPOT_PRICE_MIN_BUY = '3.6k XEC';
+ const UPDATED_CACHET_SPOT_PRICE_FIAT_MIN_BUY = '$0.11 USD';
+ expect(
+ screen.getByText(UPDATED_CACHET_SPOT_MIN_QTY),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(UPDATED_CACHET_SPOT_PRICE_MIN_BUY),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(UPDATED_CACHET_SPOT_PRICE_FIAT_MIN_BUY),
+ ).toBeInTheDocument();
+
+ const buyCachetButton = screen.getByRole('button', {
+ name: 'Buy Cachet (CACHET)',
+ });
+ expect(buyCachetButton).toBeInTheDocument();
+
+ // Query the slider by its role and aria-labelledby attribute
+ const slider = screen.getByRole('slider');
+
+ // We see a slider
+ expect(slider).toBeInTheDocument();
+
+ // Select an invalid quantity
+ // 299.99 Cachet, which would create an unacceptable remainder of 0.01 CACHET (offer min is 0.30)
+ fireEvent.change(slider, { target: { value: 29999 } });
+ // We see expected validation message
+ expect(
+ screen.getByText(/299.70 or the full offer/),
+ ).toBeInTheDocument();
+ // Buy button is disabled for this qty
+ expect(buyCachetButton).toBeDisabled();
+
+ // OK let's buy some other amount
+ fireEvent.change(slider, { target: { value: 5555 } });
+
+ // Buy button is no longer disabled
+ expect(buyCachetButton).toBeEnabled();
+
+ await userEvent.click(buyCachetButton);
+
+ // We see a confirmation modal
+ expect(
+ await screen.findByText(
+ 'Buy 55.55 Cachet (CACHET) for 666,636.8 XEC ($20.00 USD)?',
+ ),
+ ).toBeInTheDocument();
+
+ // We buy
+ await userEvent.click(screen.getByText('OK'));
+
+ // Error notification for buy we can't afford
+ expect(
+ await screen.findByText(
+ `Error: Insufficient utxos to accept this offer`,
+ ),
+ ).toBeInTheDocument();
+
+ // Guess we do buy the min then
+ fireEvent.change(slider, { target: { value: 30 } });
+
+ // We see a confirmation modal
+ expect(
+ await screen.findByText(
+ 'Buy .30 Cachet (CACHET) for 3,601.92 XEC ($0.11 USD)?',
+ ),
+ ).toBeInTheDocument();
+
+ // We buy
+ await userEvent.click(screen.getByText('OK'));
+
+ // Notification on successful buy
+ expect(
+ await screen.findByText(
+ `Bought ${UPDATED_CACHET_SPOT_MIN_QTY} Cachet (CACHET) for 3,601.92 XEC (${UPDATED_CACHET_SPOT_PRICE_FIAT_MIN_BUY})`,
+ ),
+ ).toBeInTheDocument();
+
+ // Note we can't test that offers are refreshed as we cannot dynamically adjust chronik mocks
+ // Would need regtest integration to do this
+ });
+});
diff --git a/cashtab/src/components/Agora/OrderBook/index.js b/cashtab/src/components/Agora/OrderBook/index.js
--- a/cashtab/src/components/Agora/OrderBook/index.js
+++ b/cashtab/src/components/Agora/OrderBook/index.js
@@ -58,6 +58,9 @@
OrderbookPrice,
SliderRow,
SliderInfoRow,
+ OrderBookContainer,
+ ButtonRow,
+ OrderBookLoading,
} from './styled';
import PrimaryButton, {
SecondaryButton,
@@ -72,7 +75,7 @@
import TokenIcon from 'components/Etokens/TokenIcon';
import { getAgoraPartialAcceptTokenQtyError } from 'validation';
import { QuestionIcon } from 'components/Common/CustomIcons';
-import { Alert } from 'components/Common/Atoms';
+import { Alert, Info } from 'components/Common/Atoms';
const OrderBook = ({
tokenId,
@@ -333,7 +336,7 @@
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
const [showConfirmCancelModal, setShowConfirmCancelModal] = useState(false);
- const [activeOffers, setActiveOffers] = useState([]);
+ const [activeOffers, setActiveOffers] = useState(null);
// On load, we select the offer at the 0-index
// This component sorts offers by spot price; so this is the spot offer
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -394,7 +397,7 @@
// Validate activePk as it could be null from Agora/index.js (not yet calculated)
let isMaker;
let lessThanFourOffers = false;
- if (activeOffers.length > 0) {
+ if (Array.isArray(activeOffers) && activeOffers.length > 0) {
lessThanFourOffers = activeOffers.length < 4;
selectedOffer = activeOffers[selectedIndex];
tokenSatoshisMax = BigInt(selectedOffer.token.amount);
@@ -443,6 +446,7 @@
// Shorthand variable to let us know we have all the info we need to successfully render the orderbook
// let decimals, decimalizedTokenQtyMin, decimalizedTokenQtyMax, decimalizedTokenQtyStep;
const canRenderOrderbook =
+ Array.isArray(activeOffers) &&
activeOffers.length > 0 &&
typeof selectedOffer !== 'undefined' &&
typeof tokenSatoshisMin !== 'undefined' &&
@@ -521,7 +525,7 @@
// When activeOffers loads, select the spot price and make necessary calcs
useEffect(() => {
- if (activeOffers.length > 0) {
+ if (Array.isArray(activeOffers) && activeOffers.length > 0) {
// Set selected offer to spot price when activeOffers changes from [] to active offers
}
}, [activeOffers]);
@@ -564,7 +568,7 @@
// Update the slider when the user selects a different offer
useEffect(() => {
- if (activeOffers.length > 0) {
+ if (Array.isArray(activeOffers) && activeOffers.length > 0) {
// Select the minAcceptedTokens amount every time the order changes
setTakeTokenSatoshis(
activeOffers[selectedIndex].variant.params
@@ -644,150 +648,182 @@
handleCancel={() => setShowConfirmCancelModal(false)}
/>
)}
- {!noIcon && (
- <OfferRow>
- {tokenName}
- {tokenTicker}
- </OfferRow>
- )}
- <OfferRow>
- {!noIcon && (
- <OfferIconCol lessThanFourOffers={lessThanFourOffers}>
- <OfferIcon
- title={tokenId}
- size={64}
- tokenId={tokenId}
- aria-label={`View larger icon for ${
- typeof tokenName === 'string'
- ? tokenName
- : tokenId
- }`}
- onClick={() => setShowLargeIconModal(true)}
- />
+ {Array.isArray(activeOffers) && activeOffers.length > 0 ? (
+ <OrderBookContainer>
+ {!noIcon && (
<OfferRow>
- <NftTokenIdAndCopyIcon>
- <a
- href={`${explorer.blockExplorerUrl}/tx/${tokenId}`}
- target="_blank"
- rel="noopener noreferrer"
- >
- {tokenId.slice(0, 3)}
- ...
- {tokenId.slice(-3)}
- </a>
- <CopyIconButton
- data={tokenId}
- showToast
- customMsg={`Token ID "${tokenId}" copied to clipboard`}
- />
- </NftTokenIdAndCopyIcon>
+ {tokenName}
+ {tokenTicker}
</OfferRow>
- </OfferIconCol>
- )}
- {agoraQueryError && (
- <Alert>
- Error querying agora for active offers. Try again later.
- </Alert>
- )}
- {canRenderOrderbook && (
- <DepthBarCol lessThanFourOffers={lessThanFourOffers}>
- {activeOffers.map((activeOffer, index) => {
- const { depthPercent } = activeOffer;
- const acceptPercent =
- (depthPercent * Number(takeTokenSatoshis)) /
- Number(tokenSatoshisMax);
- return (
- <OrderBookRow
- key={index}
- onClick={() => setSelectedIndex(index)}
- selected={index === selectedIndex}
- >
- <OrderbookPrice>
- {getFormattedFiatPrice(
- settings,
- userLocale,
- nanoSatoshisToXec(
- Number(
- activeOffer.spotPriceNanoSatsPerTokenSat,
- ) * parseFloat(`1e${decimals}`),
- ),
- fiatPrice,
- )}
- <DepthBar
- depthPercent={depthPercent}
- ></DepthBar>
- {index === selectedIndex && (
- <TentativeAcceptBar
- acceptPercent={acceptPercent}
- ></TentativeAcceptBar>
- )}
- </OrderbookPrice>
- </OrderBookRow>
- );
- })}
- </DepthBarCol>
- )}
- </OfferRow>
- {canRenderOrderbook && (
- <>
- <SliderRow>
- <Slider
- name={`Select buy qty ${tokenId}`}
- value={takeTokenSatoshis}
- error={takeTokenSatoshisError}
- handleSlide={handleTakeTokenSatoshisSlide}
- min={tokenSatoshisMin.toString()}
- max={tokenSatoshisMax.toString()}
- step={tokenSatoshisStep.toString()}
- />
- </SliderRow>
- <SliderRow>
- <DataAndQuestionButton>
- {decimalizedTokenQtyToLocaleFormat(
- decimalizeTokenAmount(
- takeTokenSatoshis,
- decimals,
- ),
- userLocale,
- )}{' '}
- <IconButton
- name={`Click for more info about agora partial sales`}
- icon={<QuestionIcon />}
- onClick={() => setShowAcceptedQtyInfo(true)}
- />
- </DataAndQuestionButton>
- </SliderRow>
- <SliderInfoRow>
- {toFormattedXec(askedSats, userLocale)} XEC
- </SliderInfoRow>
- {fiatPrice !== null && (
- <SliderInfoRow>
- {getFormattedFiatPrice(
- settings,
- userLocale,
- toXec(askedSats),
- fiatPrice,
- )}
- </SliderInfoRow>
)}
<OfferRow>
- {isMaker ? (
- <SecondaryButton
- onClick={() => setShowConfirmCancelModal(true)}
+ {!noIcon && (
+ <OfferIconCol
+ lessThanFourOffers={lessThanFourOffers}
>
- Cancel your offer
- </SecondaryButton>
- ) : (
- <PrimaryButton
- onClick={() => setShowConfirmBuyModal(true)}
- disabled={takeTokenSatoshisError}
+ <OfferIcon
+ title={tokenId}
+ size={64}
+ tokenId={tokenId}
+ aria-label={`View larger icon for ${
+ typeof tokenName === 'string'
+ ? tokenName
+ : tokenId
+ }`}
+ onClick={() => setShowLargeIconModal(true)}
+ />
+ <OfferRow>
+ <NftTokenIdAndCopyIcon>
+ <a
+ href={`${explorer.blockExplorerUrl}/tx/${tokenId}`}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {tokenId.slice(0, 3)}
+ ...
+ {tokenId.slice(-3)}
+ </a>
+ <CopyIconButton
+ data={tokenId}
+ showToast
+ customMsg={`Token ID "${tokenId}" copied to clipboard`}
+ />
+ </NftTokenIdAndCopyIcon>
+ </OfferRow>
+ </OfferIconCol>
+ )}
+ {canRenderOrderbook && (
+ <DepthBarCol
+ lessThanFourOffers={lessThanFourOffers}
>
- Buy {tokenName}
- {tokenTicker}
- </PrimaryButton>
+ {activeOffers.map((activeOffer, index) => {
+ const { depthPercent } = activeOffer;
+ const acceptPercent =
+ (depthPercent *
+ Number(takeTokenSatoshis)) /
+ Number(tokenSatoshisMax);
+ return (
+ <OrderBookRow
+ key={index}
+ onClick={() =>
+ setSelectedIndex(index)
+ }
+ selected={index === selectedIndex}
+ >
+ <OrderbookPrice>
+ {getFormattedFiatPrice(
+ settings,
+ userLocale,
+ nanoSatoshisToXec(
+ Number(
+ activeOffer.spotPriceNanoSatsPerTokenSat,
+ ) *
+ parseFloat(
+ `1e${decimals}`,
+ ),
+ ),
+ fiatPrice,
+ )}
+ <DepthBar
+ depthPercent={depthPercent}
+ ></DepthBar>
+ {index === selectedIndex && (
+ <TentativeAcceptBar
+ acceptPercent={
+ acceptPercent
+ }
+ ></TentativeAcceptBar>
+ )}
+ </OrderbookPrice>
+ </OrderBookRow>
+ );
+ })}
+ </DepthBarCol>
)}
</OfferRow>
+ {canRenderOrderbook && (
+ <>
+ <SliderRow>
+ <Slider
+ name={`Select buy qty ${tokenId}`}
+ value={takeTokenSatoshis}
+ error={takeTokenSatoshisError}
+ handleSlide={handleTakeTokenSatoshisSlide}
+ min={tokenSatoshisMin.toString()}
+ max={tokenSatoshisMax.toString()}
+ step={tokenSatoshisStep.toString()}
+ />
+ </SliderRow>
+ <SliderRow>
+ <DataAndQuestionButton>
+ {decimalizedTokenQtyToLocaleFormat(
+ decimalizeTokenAmount(
+ takeTokenSatoshis,
+ decimals,
+ ),
+ userLocale,
+ )}{' '}
+ <IconButton
+ name={`Click for more info about agora partial sales`}
+ icon={<QuestionIcon />}
+ onClick={() =>
+ setShowAcceptedQtyInfo(true)
+ }
+ />
+ </DataAndQuestionButton>
+ </SliderRow>
+ <SliderInfoRow>
+ {toFormattedXec(askedSats, userLocale)} XEC
+ </SliderInfoRow>
+ {fiatPrice !== null && (
+ <SliderInfoRow>
+ {getFormattedFiatPrice(
+ settings,
+ userLocale,
+ toXec(askedSats),
+ fiatPrice,
+ )}
+ </SliderInfoRow>
+ )}
+ <ButtonRow>
+ {isMaker ? (
+ <SecondaryButton
+ onClick={() =>
+ setShowConfirmCancelModal(true)
+ }
+ >
+ Cancel your offer
+ </SecondaryButton>
+ ) : (
+ <PrimaryButton
+ onClick={() =>
+ setShowConfirmBuyModal(true)
+ }
+ disabled={takeTokenSatoshisError}
+ >
+ Buy {tokenName}
+ {tokenTicker}
+ </PrimaryButton>
+ )}
+ </ButtonRow>
+ </>
+ )}
+ </OrderBookContainer>
+ ) : activeOffers === null ? (
+ <>
+ {agoraQueryError ? (
+ <Alert>
+ Error querying agora for active offers. Try again
+ later.
+ </Alert>
+ ) : (
+ <OrderBookLoading>
+ <InlineLoader />
+ </OrderBookLoading>
+ )}
</>
+ ) : (
+ <Info>No active offers for this token</Info>
)}
</>
);
diff --git a/cashtab/src/components/Agora/OrderBook/styled.js b/cashtab/src/components/Agora/OrderBook/styled.js
--- a/cashtab/src/components/Agora/OrderBook/styled.js
+++ b/cashtab/src/components/Agora/OrderBook/styled.js
@@ -6,6 +6,15 @@
import { token as tokenConfig } from 'config/token';
import { CashtabScroll } from 'components/Common/Atoms';
+export const OrderBookLoading = styled.div`
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ margin: 12px auto;
+`;
+export const OrderBookContainer = styled.div`
+ color: ${props => props.theme.contrast};
+`;
export const OfferIconCol = styled.div`
min-width: 64px;
display: flex;
@@ -123,3 +132,6 @@
width: 100%;
justify-content: center;
`;
+export const ButtonRow = styled.div`
+ margin-top: 12px;
+`;
diff --git a/cashtab/src/components/Agora/__tests__/index.test.js b/cashtab/src/components/Agora/__tests__/index.test.js
--- a/cashtab/src/components/Agora/__tests__/index.test.js
+++ b/cashtab/src/components/Agora/__tests__/index.test.js
@@ -271,8 +271,8 @@
// We have an offer
expect(screen.getByText('Token Offers')).toBeInTheDocument();
- // We see the token name and ticker above its PartialOffer
- expect(screen.getByText('Cachet (CACHET)')).toBeInTheDocument();
+ // We see the token name and ticker above its PartialOffer after OrderBooks load
+ expect(await screen.findByText('Cachet (CACHET)')).toBeInTheDocument();
// Because this offer was created by this wallet, we have the option to cancel it
expect(
@@ -496,8 +496,8 @@
expect(screen.getByText('Token Offers')).toBeInTheDocument();
// We see all token names and tickers above their PartialOffers
- expect(screen.getByText('Cachet (CACHET)')).toBeInTheDocument();
- expect(screen.getByText('Bull (BULL)')).toBeInTheDocument();
+ expect(await screen.findByText('Cachet (CACHET)')).toBeInTheDocument();
+ expect(await screen.findByText('Bull (BULL)')).toBeInTheDocument();
// For BULL, there is only one offer, so that offer is the spot price
const BULL_SPOT_MIN_QTY = '8';
@@ -786,8 +786,8 @@
expect(screen.getByText('Token Offers')).toBeInTheDocument();
// We see all token names and tickers above their PartialOffers
- expect(screen.getByText('Cachet (CACHET)')).toBeInTheDocument();
- expect(screen.getByText('Bull (BULL)')).toBeInTheDocument();
+ expect(await screen.findByText('Cachet (CACHET)')).toBeInTheDocument();
+ expect(await screen.findByText('Bull (BULL)')).toBeInTheDocument();
// We see the expected spot offer for CACHET
const CACHET_SPOT_MIN_QTY = '.20';
@@ -943,8 +943,8 @@
expect(screen.getByText('Token Offers')).toBeInTheDocument();
// We see all token names and tickers above their PartialOffers
- expect(screen.getByText('Cachet (CACHET)')).toBeInTheDocument();
- expect(screen.getByText('Bull (BULL)')).toBeInTheDocument();
+ expect(await screen.findByText('Cachet (CACHET)')).toBeInTheDocument();
+ expect(await screen.findByText('Bull (BULL)')).toBeInTheDocument();
// We see the expected spot offer for CACHET
const CACHET_SPOT_MIN_QTY = '.20';
diff --git a/cashtab/src/components/Agora/fixtures/mocks.js b/cashtab/src/components/Agora/fixtures/mocks.js
--- a/cashtab/src/components/Agora/fixtures/mocks.js
+++ b/cashtab/src/components/Agora/fixtures/mocks.js
@@ -1394,3 +1394,37 @@
},
},
};
+
+export const CachedCachet = {
+ tokenType: { protocol: 'SLP', type: 'SLP_TOKEN_TYPE_FUNGIBLE', number: 1 },
+ genesisInfo: {
+ tokenTicker: 'CACHET',
+ tokenName: 'Cachet',
+ url: 'https://cashtab.com/',
+ decimals: 2,
+ hash: '',
+ },
+ timeFirstSeen: 1711776546,
+ genesisSupply: '100000.00',
+ genesisOutputScripts: [
+ '76a91495e79f51d4260bc0dc3ba7fb77c7be92d0fbdd1d88ac',
+ ],
+ genesisMintBatons: 1,
+ block: {
+ height: 838192,
+ hash: '0000000000000000132232769161d6211f7e6e20cf63b26e5148890aacd26962',
+ timestamp: 1711779364,
+ },
+};
+
+export const SettingsUsd = {
+ autoCameraOff: false,
+ autoCameraOn: false,
+ balanceVisible: true,
+ fiatCurrency: 'usd',
+ hideMessagesFromUnknownSenders: false,
+ minFeeSends: true,
+ sendModal: false,
+ showMessages: false,
+ toggleHideBalance: false,
+};
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
@@ -30,6 +30,7 @@
import { formatDate, getFormattedFiatPrice } from 'utils/formatting';
import TokenIcon from 'components/Etokens/TokenIcon';
import { explorer } from 'config/explorer';
+import { token as tokenConfig } from 'config/token';
import { queryAliasServer } from 'alias';
import aliasSettings from 'config/alias';
import cashaddr from 'ecashaddrjs';
@@ -128,6 +129,7 @@
AgoraPartialAdSignatory,
} from 'ecash-agora';
import * as wif from 'wif';
+import OrderBook from 'components/Agora/OrderBook';
const Token = () => {
let navigate = useNavigate();
@@ -143,6 +145,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;
@@ -224,6 +233,7 @@
}
}
+ const [isBlacklisted, setIsBlacklisted] = useState(null);
const [chronikQueryError, setChronikQueryError] = useState(false);
const [nftTokenIds, setNftTokenIds] = useState([]);
const [nftChildGenesisInput, setNftChildGenesisInput] = useState([]);
@@ -403,6 +413,28 @@
)}) per token`;
};
+ const getTokenBlacklistStatus = async () => {
+ // Fetch server-maintained blacklist
+ let blacklistStatus;
+ try {
+ blacklistStatus = (
+ await (
+ await fetch(
+ `${tokenConfig.blacklistServerUrl}/blacklist/${tokenId}`,
+ )
+ ).json()
+ ).isBlacklisted;
+ setIsBlacklisted(blacklistStatus);
+ } catch (err) {
+ console.error(
+ `Error fetching blacklistStatus from ${tokenConfig.blacklistServerUrl}/blacklist/${tokenId}`,
+ err,
+ );
+ // Assume it's ok
+ setIsBlacklisted(false);
+ }
+ };
+
const getUncachedTokenInfo = async () => {
let tokenUtxos;
try {
@@ -477,6 +509,9 @@
// Get token info that is not practical to cache as it is subject to change
// Note that we need decimals from cache for supply to be accurate
getUncachedTokenInfo();
+
+ // Get token blacklist status
+ getTokenBlacklistStatus();
}
}, [tokenId, cashtabCache.tokens.get(tokenId)]);
@@ -2063,7 +2098,32 @@
)}
</TokenStatsCol>
</TokenStatsTable>
-
+ {isBlacklisted && (
+ <Alert>
+ Cashtab does not support trading this token
+ </Alert>
+ )}
+ {isSupportedToken &&
+ pk !== false &&
+ isBlacklisted !== null &&
+ !isBlacklisted && (
+ <OrderBook
+ tokenId={tokenId}
+ noIcon
+ cachedTokenInfo={cashtabCache.tokens.get(
+ tokenId,
+ )}
+ settings={settings}
+ userLocale={userLocale}
+ fiatPrice={fiatPrice}
+ activePk={pk}
+ wallet={wallet}
+ ecc={ecc}
+ chronik={chronik}
+ agora={agora}
+ chaintipBlockheight={chaintipBlockheight}
+ />
+ )}
{isNftParent && nftTokenIds.length > 0 && (
<>
<NftTitle>NFTs in this Collection</NftTitle>
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
@@ -23,6 +23,8 @@
slp1FixedCachet,
} from 'components/Etokens/fixtures/mocks';
import { Ecc, initWasm } from 'ecash-lib';
+import { MockAgora } from '../../../../../modules/mock-chronik-client';
+import { token as tokenConfig } from 'config/token';
// https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function
Object.defineProperty(window, 'matchMedia', {
@@ -130,10 +132,87 @@
});
it('For a fungible SLP token, renders the Token screen with sale by default and expected inputs', async () => {
+ // Need to mock agora API endpoints
+ const mockedAgora = new MockAgora();
+
+ // No active offers
+ mockedAgora.setActiveOffersByTokenId(SEND_TOKEN_TOKENID, []);
+
+ // Mock not blacklisted
+ when(fetch)
+ .calledWith(
+ `${tokenConfig.blacklistServerUrl}/blacklist/${SEND_TOKEN_TOKENID}`,
+ )
+ .mockResolvedValue({
+ json: () => Promise.resolve({ isBlacklisted: false }),
+ });
+
+ render(
+ <CashtabTestWrapper
+ chronik={mockedChronik}
+ ecc={ecc}
+ agora={mockedAgora}
+ route={`/token/${SEND_TOKEN_TOKENID}`}
+ />,
+ );
+
+ // Wait for element to get token info and load
+ expect((await screen.findAllByText(/BEAR/))[0]).toBeInTheDocument();
+
+ // Wait for Cashtab to recognize this is an SLP1 fungible token and enable Sale
+ expect(await screen.findByTitle('Toggle Sell SLP')).toHaveProperty(
+ 'checked',
+ true,
+ );
+
+ const totalQtyInput = screen.getByPlaceholderText('Offered qty');
+ const minQtyInput = screen.getByPlaceholderText('Min buy');
+
+ // Input fields are rendered
+ expect(totalQtyInput).toBeInTheDocument();
+ expect(minQtyInput).toBeInTheDocument();
+
+ // Qty inputs are not disabled
+ expect(totalQtyInput).toHaveProperty('disabled', false);
+ expect(minQtyInput).toHaveProperty('disabled', false);
+
+ // Price input is disabled as qty inputs are at 0 value
+ expect(
+ screen.getByPlaceholderText('Enter SLP list price (per token)'),
+ ).toHaveProperty('disabled', true);
+
+ // List button is present and disabled
+ expect(
+ screen.getByRole('button', { name: /List BearNip/ }),
+ ).toHaveProperty('disabled', true);
+
+ // OrderBook is rendered
+ // NB OrderBook behavior is tested independently, we only test that it appears as expected here
+ expect(
+ await screen.findByText('No active offers for this token'),
+ ).toBeInTheDocument();
+ });
+ it('We show an alert and do not render the Orderbook for a blacklisted token', async () => {
+ // Need to mock agora API endpoints
+ const mockedAgora = new MockAgora();
+
+ // No active offers
+ mockedAgora.setActiveOffersByTokenId(SEND_TOKEN_TOKENID, []);
+
+ // Mock blacklisted
+ when(fetch)
+ .calledWith(
+ `${tokenConfig.blacklistServerUrl}/blacklist/${SEND_TOKEN_TOKENID}`,
+ )
+ .mockResolvedValue({
+ json: () => Promise.resolve({ isBlacklisted: true }),
+ });
+
render(
<CashtabTestWrapper
chronik={mockedChronik}
ecc={ecc}
+ agora={mockedAgora}
route={`/token/${SEND_TOKEN_TOKENID}`}
/>,
);
@@ -167,6 +246,14 @@
expect(
screen.getByRole('button', { name: /List BearNip/ }),
).toHaveProperty('disabled', true);
+
+ // OrderBook is NOT rendered
+ // We show expected blacklist notice
+ expect(
+ await screen.findByText(
+ 'Cashtab does not support trading this token',
+ ),
+ ).toBeInTheDocument();
});
it('Accepts a valid ecash: prefixed address', async () => {
render(

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 26, 11:45 (16 h, 49 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5573437
Default Alt Text
D17064.id50626.diff (58 KB)

Event Timeline