diff --git a/src/components/earn/EarnPage.test.tsx b/src/components/earn/EarnPage.test.tsx
index 2c4dcaa7..fe6c0954 100644
--- a/src/components/earn/EarnPage.test.tsx
+++ b/src/components/earn/EarnPage.test.tsx
@@ -2,6 +2,7 @@ import type React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import * as JAM from '@/constants/jam'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { jmSessionStore } from '@/store/jmSessionStore'
import type { EarnFormValues } from './EarnForm'
@@ -10,6 +11,12 @@ import { EarnPage } from './EarnPage'
const mocks = vi.hoisted(() => ({
developerMode: false,
feeConfigMissing: false,
+ orderbookData: vi.fn<() => unknown>(),
+ orderbookQueryOptions: vi.fn(),
+ orderbookQueryState: {
+ isError: false,
+ isLoading: false,
+ },
scrollToTop: vi.fn(),
startMaker: vi.fn(),
startMutationState: {
@@ -76,9 +83,13 @@ vi.mock('@tanstack/react-query', () => ({
reset: vi.fn(),
}
}),
- useQuery: vi.fn(() => ({
- refetch: mocks.stopMakerRefetch,
- })),
+ useQuery: vi.fn((options: { queryKey?: unknown[] }) => {
+ if (options.queryKey?.[0] === 'orderbook') {
+ mocks.orderbookQueryOptions(options)
+ return { ...mocks.orderbookQueryState, data: mocks.orderbookData() }
+ }
+ return { refetch: mocks.stopMakerRefetch }
+ }),
}))
vi.mock('react-i18next', () => ({
@@ -194,9 +205,24 @@ vi.mock('./MoveToJarDialog', () => ({
}))
vi.mock('./OfferCard', () => ({
- OfferCard: ({ children, nickname }: { children?: React.ReactNode; nickname?: string }) => (
+ OfferCard: ({
+ children,
+ nickname,
+ orderbookStatus,
+ orderbookOffer,
+ fidelityBond,
+ }: {
+ children?: React.ReactNode
+ nickname?: string
+ orderbookStatus?: string
+ orderbookOffer?: { fidelity_bond_value?: number }
+ fidelityBond?: { amount?: number }
+ }) => (
offer-card:{nickname}
+ orderbook-status:{orderbookStatus}
+ bond-value:{orderbookOffer?.fidelity_bond_value}
+ bond-amount:{fidelityBond?.amount}
{children}
),
@@ -251,6 +277,11 @@ describe('EarnPage', () => {
beforeEach(() => {
mocks.developerMode = false
mocks.feeConfigMissing = false
+ mocks.orderbookData.mockReset()
+ mocks.orderbookData.mockReturnValue(undefined)
+ mocks.orderbookQueryOptions.mockReset()
+ mocks.orderbookQueryState.isError = false
+ mocks.orderbookQueryState.isLoading = false
mocks.scrollToTop.mockReset()
mocks.startMaker.mockReset()
mocks.startMaker.mockResolvedValue({})
@@ -342,6 +373,51 @@ describe('EarnPage', () => {
expect(mocks.stopMakerRefetch).toHaveBeenCalledWith({ throwOnError: true })
})
+ it('shows the current offer and fidelity bond from the local orderbook', () => {
+ setSession({
+ maker_running: true,
+ offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }],
+ })
+ mocks.orderbookData.mockReturnValue({
+ offers: [{ counterparty: 'maker-a', oid: 7, fidelity_bond_value: 42_000 }],
+ fidelitybonds: [
+ { counterparty: 'maker-a', amount: 50_000, locktime: 1_700_000_000 },
+ { counterparty: 'maker-a', amount: 100_000, locktime: 1_800_000_000 },
+ ],
+ })
+
+ render()
+
+ expect(screen.getByText('orderbook-status:visible')).toBeInTheDocument()
+ expect(screen.getByText('bond-value:42000')).toBeInTheDocument()
+ expect(screen.getByText('bond-amount:100000')).toBeInTheDocument()
+ })
+
+ it('polls until the current offer is visible, then slows down', () => {
+ setSession({
+ maker_running: true,
+ offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }],
+ })
+
+ render()
+
+ const { refetchInterval } = mocks.orderbookQueryOptions.mock.calls[0][0] as {
+ refetchInterval: (query: {
+ state: {
+ data?: { offers: Array<{ counterparty: string; oid: number }> }
+ error?: Error | null
+ }
+ }) => number
+ }
+ const visibleData = { offers: [{ counterparty: 'maker-a', oid: 7 }] }
+
+ expect(refetchInterval({ state: {} })).toBe(JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL)
+ expect(refetchInterval({ state: { data: visibleData } })).toBe(JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL)
+ expect(refetchInterval({ state: { data: visibleData, error: new Error('offline') } })).toBe(
+ JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL,
+ )
+ })
+
it('shows waiting states while maker updates', () => {
mocks.startMutationState.isSuccess = true
diff --git a/src/components/earn/EarnPage.tsx b/src/components/earn/EarnPage.tsx
index 27815ec4..6f9b2ab9 100644
--- a/src/components/earn/EarnPage.tsx
+++ b/src/components/earn/EarnPage.tsx
@@ -22,6 +22,7 @@ import { useApiClient } from '@/hooks/useApiClient'
import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { useRefreshSession } from '@/hooks/useRefreshSession'
+import * as OrderbookApi from '@/lib/api/orderbook'
import { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import { withQueryDelay } from '@/lib/queryClient'
@@ -80,6 +81,40 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false)
const isCurrentOfferAvailable = jmSession?.offer_list && jmSession.offer_list.length > 0
+ const currentOffer = jmSession?.offer_list?.[0]
+ const isCurrentOrderbookOffer = (offer: OrderbookApi.OrderbookOffer) =>
+ offer.counterparty === jmSession?.nickname && String(offer.oid) === String(currentOffer?.oid)
+
+ const {
+ data: orderbookData,
+ isFetching: orderbookIsFetching,
+ isError: orderbookIsError,
+ } = useQuery({
+ queryKey: ['orderbook'],
+ queryFn: withQueryDelay(OrderbookApi.fetchOrderbook, {
+ // avoid flickering and let user briefly know that something is happening in the background
+ delayBefore: 1_000,
+ }),
+ enabled: makerRunning && !!currentOffer && !!jmSession?.nickname,
+ staleTime: Number.POSITIVE_INFINITY,
+ refetchInterval: (query) =>
+ query.state.error || !query.state.data?.offers.some((offer) => isCurrentOrderbookOffer(offer))
+ ? JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL
+ : JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL,
+ })
+ const currentOrderbookOffer = orderbookData?.offers.find((offer) => isCurrentOrderbookOffer(offer))
+ const currentOrderbookFidelityBond =
+ Number(currentOrderbookOffer?.fidelity_bond_value) > 0
+ ? orderbookData?.fidelitybonds?.findLast((bond) => bond.counterparty === jmSession?.nickname)
+ : undefined
+ const orderbookStatus =
+ !jmSession?.nickname || orderbookIsFetching
+ ? 'checking'
+ : orderbookIsError
+ ? 'error'
+ : currentOrderbookOffer
+ ? 'visible'
+ : 'missing'
const stopMakerQueryOptions = stopmakerOptions({
client,
@@ -261,6 +296,9 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
className="motion-safe:animate-in blur-in"
value={jmSession.offer_list[0]}
nickname={jmSession.nickname}
+ orderbookStatus={orderbookStatus}
+ orderbookOffer={currentOrderbookOffer}
+ fidelityBond={currentOrderbookFidelityBond}
>