feat(earn): show own offer status in local orderbook (#1420)

This commit is contained in:
Parth 2026-08-11 01:34:39 +05:30 committed by GitHub
parent 1535a8148e
commit c832b80a2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 426 additions and 15 deletions

View file

@ -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 }
}) => (
<div>
offer-card:{nickname}
<span>orderbook-status:{orderbookStatus}</span>
<span>bond-value:{orderbookOffer?.fidelity_bond_value}</span>
<span>bond-amount:{fidelityBond?.amount}</span>
{children}
</div>
),
@ -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(<EarnPage walletFileName="wallet.jmdat" />)
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(<EarnPage walletFileName="wallet.jmdat" />)
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

View file

@ -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}
>
<Button type="button" onClick={() => void onStop()} className="w-full" size="lg">
{isWaitingMakerStop ? (

View file

@ -1,6 +1,7 @@
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import { OfferCard } from './OfferCard'
type Offer = NonNullable<SessionResponse['offer_list']>[number]
@ -79,4 +80,41 @@ describe('OfferCard', () => {
expect(screen.getByTestId('child')).toBeInTheDocument()
})
it('shows whether the offer is visible in the local orderbook', () => {
const { rerender } = render(<OfferCard value={baseOffer} nickname="JMBot" orderbookStatus="visible" />)
expect(screen.getByText('earn.current.text_orderbook_visible')).toBeInTheDocument()
rerender(<OfferCard value={baseOffer} nickname="JMBot" orderbookStatus="missing" />)
expect(screen.getByText('earn.current.text_orderbook_missing')).toBeInTheDocument()
})
it('shows the advertised fidelity bond details', () => {
withRuntimeLocale('de-DE', () => {
render(
<OfferCard
value={baseOffer}
nickname="JMBot"
orderbookStatus="visible"
orderbookOffer={{
counterparty: 'JMBot',
oid: 123,
ordertype: 'sw0absoffer',
minsize: 10_000,
maxsize: 50_000,
txfee: 500,
cjfee: 1_000,
fidelity_bond_value: 42_000,
}}
fidelityBond={{ counterparty: 'JMBot', amount: 100_000, locktime: 1_800_000_000 }}
/>,
)
expect(screen.getByText('earn.current.text_fidelity_bond')).toBeInTheDocument()
expect(screen.getByText('earn.current.text_bond_value: 42.000')).toBeInTheDocument()
expect(screen.getByText('100000')).toBeInTheDocument()
expect(screen.getByText(/earn\.current\.text_bond_locktime/u)).toBeInTheDocument()
})
})
})

View file

@ -1,11 +1,22 @@
import type { PropsWithChildren } from 'react'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import type { TFunction } from 'i18next'
import { FingerprintIcon, HandCoinsIcon, Maximize2Icon, Minimize2Icon } from 'lucide-react'
import {
CircleAlertIcon,
CircleCheckIcon,
FingerprintIcon,
HandCoinsIcon,
Maximize2Icon,
Minimize2Icon,
PickaxeIcon,
SearchIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { OrderbookFidelityBond, OrderbookOffer } from '@/lib/api/orderbook'
import { cn, factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '@/lib/utils'
import { Balance } from '../ui/jam/Balance'
import { Label } from '../ui/label'
@ -36,16 +47,50 @@ interface OfferCardProps {
className?: string
value: Offer
nickname: SessionResponse['nickname']
orderbookStatus?: 'checking' | 'visible' | 'missing' | 'error'
orderbookOffer?: OrderbookOffer
fidelityBond?: OrderbookFidelityBond
}
export function OfferCard({ className, value, nickname, children }: PropsWithChildren<OfferCardProps>) {
const orderbookStatusEntry = (value: OfferCardProps['orderbookStatus'], t: TFunction) => {
return value === 'visible'
? { icon: CircleCheckIcon, text: t('earn.current.text_orderbook_visible'), variant: 'success' as const }
: value === 'missing'
? { icon: SearchIcon, text: t('earn.current.text_orderbook_missing'), variant: 'warning' as const }
: value === 'error'
? { icon: CircleAlertIcon, text: t('earn.current.text_orderbook_error'), variant: 'destructive' as const }
: value === 'checking'
? { icon: SearchIcon, text: t('earn.current.text_orderbook_checking'), variant: 'muted' as const }
: undefined
}
export function OfferCard({
className,
value,
nickname,
orderbookStatus,
orderbookOffer,
fidelityBond,
children,
}: PropsWithChildren<OfferCardProps>) {
const { t } = useTranslation()
const bondValue = Number(orderbookOffer?.fidelity_bond_value) || 0
const offerInOrderbookStatus = orderbookStatusEntry(orderbookStatus, t)
const bondWarning = bondValue === 0 || orderbookOffer?.fidelity_bond_verification_stale === true
return (
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
<CardHeader>
<CardTitle>{t('earn.current.text_offer')}</CardTitle>
<CardDescription></CardDescription>
<CardDescription>
{offerInOrderbookStatus && (
<Badge className="whitespace-normal" variant={offerInOrderbookStatus.variant}>
<offerInOrderbookStatus.icon className="shrink-0" />
{offerInOrderbookStatus.text}
</Badge>
)}
</CardDescription>
<CardAction>
<Tooltip>
<TooltipTrigger asChild>
@ -100,8 +145,9 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
</div>
</div>
{!!value?.txfee && (
<div className="flex items-center gap-4">
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<PickaxeIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_txfee')}</Label>
<span className="text-muted-foreground text-sm">
<Balance valueString={String(value?.txfee || '0')} />
@ -109,6 +155,45 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
</div>
</div>
)}
{fidelityBond !== undefined && (
<div className="flex min-w-0 items-start gap-4 sm:col-span-full">
<ShieldCheckIcon
className={cn('mt-0.5 shrink-0', {
'text-brand-success': bondValue > 0,
'text-brand-warning': bondWarning,
})}
/>
<div className="min-w-0 flex-1">
<Label
className={cn('font-semibold', {
'text-brand-success': bondValue > 0,
'text-brand-warning': bondWarning,
})}
>
{t('earn.current.text_fidelity_bond')}
</Label>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col items-start gap-2 sm:flex-row sm:items-center">
<span className={bondValue <= 0 ? 'text-muted-foreground' : undefined}>
{t('earn.current.text_bond_value')}: {Math.floor(bondValue).toLocaleString()}
</span>
</div>
<div className="text-muted-foreground">
<Balance valueString={String(fidelityBond.amount)} />
<span className="text-muted-foreground">
{t('earn.current.text_bond_locktime', {
date: new Date(fidelityBond.locktime * 1_000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
})}
</span>
</div>
</div>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex-col gap-2">{children}</CardFooter>
</Card>

View file

@ -87,6 +87,15 @@ export const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = Math.max(
1,
)
export const WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL, 15_000),
1_000,
)
export const VISIBLE_ORDERBOOK_POLLING_INTERVAL: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_VISIBLE_ORDERBOOK_POLLING_INTERVAL, 120_000),
1_000,
)
export const RUNNING_COINJOIN_POLLING_INTERVAL: Milliseconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_RUNNING_COINJOIN_POLLING_INTERVAL, 5_000),
1_000,

View file

@ -572,7 +572,14 @@
"text_txfee": "Transaction Fee",
"text_offer_id": "Offer ID",
"text_offer_type_absolute": "absolute",
"text_offer_type_relative": "relative"
"text_offer_type_relative": "relative",
"text_orderbook_checking": "Checking local orderbook...",
"text_orderbook_visible": "Visible in local orderbook",
"text_orderbook_missing": "Not found in local orderbook yet",
"text_orderbook_error": "Could not check local orderbook",
"text_fidelity_bond": "Fidelity Bond",
"text_bond_value": "Bond value",
"text_bond_locktime": "locked until {{date}}"
},
"report": {
"title": "Earnings Report",

View file

@ -5,11 +5,18 @@ export interface OrderbookOffer {
counterparty: string
oid: number
ordertype: OfferType
minsize: AmountSats | null | undefined
maxsize: AmountSats | null | undefined
txfee: number | null | undefined
cjfee: number | null | undefined
fidelity_bond_value: number | null | undefined
minsize?: AmountSats | null
maxsize?: AmountSats | null
txfee?: number | null
cjfee?: number | null
fidelity_bond_value?: number | null
fidelity_bond_verification_stale?: boolean | null
directory_nodes?: string[] | null
directly_reachable?: boolean | null
// TODO: many more fields, e.g.:
// fidelity_bond_value
// fidelity_bond_verified
// features { neutrino_compat: true, nick_auth: true, peerlist_features: true, … }
}
export interface OrderbookFidelityBond {

View file

@ -0,0 +1,151 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { OfferCard } from '@/components/earn/OfferCard'
const meta: Meta<typeof OfferCard> = {
title: 'Jam/OfferCard',
component: OfferCard,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof OfferCard>
export const Absolute: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0absoffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '21',
},
nickname: 'TEST7i74jfC9M8MV',
},
}
export const Relative: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
},
}
export const Checking: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'checking',
},
}
export const Visible: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'visible',
},
}
export const Missing: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'missing',
},
}
export const Error: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'error',
},
}
export const RelativeWithFidelityBondNotYetInLocalOrderbook: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'visible',
orderbookOffer: {
counterparty: 'TESTCKqXnDjxnFHN',
oid: 0,
ordertype: 'sw0reloffer',
fidelity_bond_value: 0,
},
fidelityBond: {
counterparty: 'TESTCKqXnDjxnFHN',
amount: 123_456_789,
locktime: 1_000_000,
},
},
}
export const RelativeWithFidelityBondInLocalOrderbook: Story = {
args: {
value: {
oid: 0,
ordertype: 'sw0reloffer',
minsize: 90_463,
maxsize: 9_008_744_958,
txfee: 0,
cjfee: '0.000021',
},
nickname: 'TESTCKqXnDjxnFHN',
orderbookStatus: 'visible',
orderbookOffer: {
counterparty: 'TESTCKqXnDjxnFHN',
oid: 0,
ordertype: 'sw0reloffer',
fidelity_bond_value: 123_456_789.1337,
},
fidelityBond: {
counterparty: 'TESTCKqXnDjxnFHN',
amount: 123_456_789,
locktime: 0,
},
},
}