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,612 @@
+// 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 { when } from 'jest-when';
+import {
+    initializeCashtabStateForTests,
+    clearLocalForage,
+} from 'components/App/fixtures/helpers';
+import appConfig from 'config/app';
+import 'fake-indexeddb/auto';
+import localforage from 'localforage';
+import {
+    agoraPartialAlphaWallet,
+    agoraOfferCachetAlphaOne,
+    agoraOfferCachetAlphaTwo,
+    agoraOfferCachetBetaOne,
+    cachetCacheMocks,
+    bullCacheMocks,
+    scamCacheMocks,
+    agoraPartialBetaWallet,
+    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'));
+
+        screen.debug(null, Infinity);
+
+        // 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,
@@ -179,11 +182,13 @@
         // Note that broadcastTx will accept cancelTxSer
         // But hex is a better way to store raw txs for integration tests
         const hex = toHex(cancelTxSer);
+        console.log(`hex`, hex);
 
         // Broadcast the cancel tx
         let resp;
         try {
             resp = await chronik.broadcastTx(hex);
+            console.log(`resp`, resp);
             toast(
                 <TokenSentLink
                     href={`${explorer.blockExplorerUrl}/tx/${resp.txid}`}
@@ -288,6 +293,7 @@
 
         // We need hex so we can log it to get integration test mocks
         const hex = toHex(acceptTxSer);
+        console.log(`hex`, hex);
 
         let resp;
         try {
@@ -333,7 +339,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);
@@ -388,13 +394,13 @@
         ) : (
             <InlineLoader />
         );
-
+    console.log(`tokenName`, tokenName);
     // Determine if the active wallet created this offer
     // Used to render Buy or Cancel option to the user
     // 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);
@@ -442,7 +448,9 @@
 
     // Shorthand variable to let us know we have all the info we need to successfully render the orderbook
     // let decimals, decimalizedTokenQtyMin, decimalizedTokenQtyMax, decimalizedTokenQtyStep;
+    console.log('Array.isArray(activeOffers)', Array.isArray(activeOffers));
     const canRenderOrderbook =
+        Array.isArray(activeOffers) &&
         activeOffers.length > 0 &&
         typeof selectedOffer !== 'undefined' &&
         typeof tokenSatoshisMin !== 'undefined' &&
@@ -453,6 +461,7 @@
         typeof decimalizedTokenQtyMax !== 'undefined' &&
         typeof decimalizedTokenQtyStep !== 'undefined' &&
         typeof isMaker === 'boolean';
+    console.log(`canRenderOrderbook`, canRenderOrderbook);
 
     /**
      * Get all activeOffers for this tokenId
@@ -463,6 +472,7 @@
     const fetchAndPrepareActiveOffers = async () => {
         try {
             const activeOffers = await agora.activeOffersByTokenId(tokenId);
+            console.log(`activeOffers`, activeOffers);
 
             // Calculate a spot price for each offer
             // We need to do this because we need to sort them to get the "true" spot price, i.e. the lowest price
@@ -521,7 +531,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 +574,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 +654,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/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
@@ -128,6 +128,7 @@
     AgoraPartialAdSignatory,
 } from 'ecash-agora';
 import * as wif from 'wif';
+import OrderBook from 'components/Agora/OrderBook';
 
 const Token = () => {
     let navigate = useNavigate();
@@ -143,6 +144,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;
 
@@ -2064,6 +2072,23 @@
                         </TokenStatsCol>
                     </TokenStatsTable>
 
+                    {isSupportedToken && pk !== false && (
+                        <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>