Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F13711359
D17064.id50626.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
58 KB
Subscribers
None
D17064.id50626.diff
View Options
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
Details
Attached
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)
Attached To
D17064: [Cashtab] Add OrderBook (with no icon) to token info page
Event Timeline
Log In to Comment