diff --git a/web-v2/src/App.tsx b/web-v2/src/App.tsx index e66bcf8..315026a 100644 --- a/web-v2/src/App.tsx +++ b/web-v2/src/App.tsx @@ -8,6 +8,7 @@ import { AppProviders } from '@/providers/AppProviders' import ErrorBoundary from './components/ErrorBoundary' import BorrowPage from './pages/Borrow' import DashboardPage from './pages/Dashboard' +import DemoPage from './pages/Demo' import DesignSystemPage from './pages/DesignSystem' import SupplyPage from './pages/Supply' @@ -35,6 +36,10 @@ const router = createBrowserRouter([ path: RoutePath.DesignSystem, element: , }, + { + path: RoutePath.Demo, + element: , + }, ] : []), ], diff --git a/web-v2/src/api/indexer/hooks.ts b/web-v2/src/api/indexer/hooks.ts index 15a5061..9a5a1cd 100644 --- a/web-v2/src/api/indexer/hooks.ts +++ b/web-v2/src/api/indexer/hooks.ts @@ -1,4 +1,9 @@ -import { useQuery, type UseQueryResult } from '@tanstack/react-query' +import { + type QueryKey, + useQuery, + type UseQueryOptions, + type UseQueryResult, +} from '@tanstack/react-query' import { GC_TIME_MS, STALE_TIME_MS } from '../staleTime' import { @@ -8,17 +13,47 @@ import { fetchOfferParticipants, fetchOfferParticipantsHistory, fetchOffers, + fetchOffersBatch, fetchOfferUtxos, type ListOffersParams, } from './methods' import { offersQueryKeys } from './queryKeys' import type { OfferDetails, OfferParticipant, OfferShort, OfferUtxo } from './schemas' -export function useOffers(params: ListOffersParams = {}): UseQueryResult { +export function useOffers( + params: ListOffersParams = {}, + options: { + refetchInterval?: number + placeholderData?: UseQueryOptions< + OfferShort[], + Error, + OfferShort[], + QueryKey + >['placeholderData'] + } = {}, +): UseQueryResult { return useQuery({ queryKey: offersQueryKeys.list(params), queryFn: ({ signal }) => fetchOffers(params, { signal }), staleTime: STALE_TIME_MS.medium, + refetchInterval: options.refetchInterval, + placeholderData: options.placeholderData, + }) +} + +// Fetch offers by exact id list (POST /offers/batch). Use for the user's own +// offers, where the id set is known and must be resolved fully — unlike the +// paginated `useOffers`, which only returns one page. +export function useOffersBatch( + ids: string[], + options: { refetchInterval?: number } = {}, +): UseQueryResult { + return useQuery({ + queryKey: offersQueryKeys.batch(ids), + queryFn: ({ signal }) => fetchOffersBatch(ids, { signal }), + staleTime: STALE_TIME_MS.realtime, + refetchInterval: options.refetchInterval, + enabled: ids.length > 0, }) } @@ -59,20 +94,28 @@ export function useOfferParticipantsHistory(offerId: string): UseQueryResult { +export function useOfferIdsByScript( + scriptPubkeyHex: string, + options: { refetchInterval?: number } = {}, +): UseQueryResult { return useQuery({ queryKey: offersQueryKeys.byScript(scriptPubkeyHex), queryFn: ({ signal }) => fetchOfferIdsByScript(scriptPubkeyHex, { signal }), staleTime: STALE_TIME_MS.realtime, + refetchInterval: options.refetchInterval, enabled: !!scriptPubkeyHex, }) } -export function useOfferIdsByBorrowerPubkey(borrowerPubkeyHex: string): UseQueryResult { +export function useOfferIdsByBorrowerPubkey( + borrowerPubkeyHex: string, + options: { refetchInterval?: number } = {}, +): UseQueryResult { return useQuery({ queryKey: offersQueryKeys.byBorrower(borrowerPubkeyHex), queryFn: ({ signal }) => fetchOfferIdsByBorrowerPubkey(borrowerPubkeyHex, { signal }), staleTime: STALE_TIME_MS.realtime, + refetchInterval: options.refetchInterval, enabled: !!borrowerPubkeyHex, }) } diff --git a/web-v2/src/api/indexer/methods.ts b/web-v2/src/api/indexer/methods.ts index 4200511..be5a992 100644 --- a/web-v2/src/api/indexer/methods.ts +++ b/web-v2/src/api/indexer/methods.ts @@ -41,11 +41,17 @@ function postBatch( }) } +export type SortDir = 'asc' | 'desc' + export interface ListOffersParams { status?: OfferStatus asset?: string limit?: number offset?: number + // Server-side sort. NOTE: backend does not honor these yet — sent as a + // forward-compatible convention; results are currently returned unsorted. + sortBy?: string + sortDir?: SortDir } function toQueryParams(params: ListOffersParams): Record { @@ -54,6 +60,8 @@ function toQueryParams(params: ListOffersParams): Record { if (params.asset) queryParams.asset = params.asset if (params.limit !== undefined) queryParams.limit = String(params.limit) if (params.offset !== undefined) queryParams.offset = String(params.offset) + if (params.sortBy) queryParams.sort_by = params.sortBy + if (params.sortDir) queryParams.sort_dir = params.sortDir return queryParams } diff --git a/web-v2/src/api/indexer/queryKeys.ts b/web-v2/src/api/indexer/queryKeys.ts index ed6f083..a4148c5 100644 --- a/web-v2/src/api/indexer/queryKeys.ts +++ b/web-v2/src/api/indexer/queryKeys.ts @@ -4,9 +4,10 @@ import type { ListOffersParams } from './methods' export const offersQueryKeys = { all: ['offers'] as const, - list: ({ status, asset, limit, offset }: ListOffersParams) => - ['offers', 'list', status, asset, limit, offset] as const, + list: ({ status, asset, limit, offset, sortBy, sortDir }: ListOffersParams) => + ['offers', 'list', status, asset, limit, offset, sortBy, sortDir] as const, detail: (offerId: string) => ['offers', 'detail', offerId] as const, + batch: (ids: string[]) => ['offers', 'batch', [...ids].sort()] as const, utxos: (offerId: string) => ['offers', 'utxos', offerId] as const, participants: (offerId: string) => ['offers', 'participants', offerId] as const, participantsHistory: (offerId: string) => ['offers', 'participants-history', offerId] as const, diff --git a/web-v2/src/components/AppLayout.tsx b/web-v2/src/components/AppLayout.tsx index ead0909..e3be142 100644 --- a/web-v2/src/components/AppLayout.tsx +++ b/web-v2/src/components/AppLayout.tsx @@ -1,36 +1,73 @@ +import { useCallback } from 'react' import { Link, Outlet } from 'react-router-dom' +import ArrowSquareOutIcon from '@/components/icons/ArrowSquareOutIcon' +import BellIcon from '@/components/icons/BellIcon' +import { UiButton } from '@/components/ui/UiButton' +import { WalletButton } from '@/components/WalletButton' import { env } from '@/constants/env' import { RoutePath } from '@/constants/routes' +const ABOUT_SIMPLICITY_URL = 'https://github.com/BlockstreamResearch/simplicity' + const NAV = [ { to: RoutePath.Dashboard, label: 'Dashboard' }, { to: RoutePath.Borrow, label: 'Borrow' }, { to: RoutePath.Supply, label: 'Supply' }, - ...(env.DEV ? [{ to: RoutePath.DesignSystem, label: 'System' }] : []), + ...(env.DEV + ? [ + { to: RoutePath.DesignSystem, label: 'System' }, + { to: RoutePath.Demo, label: 'Demo' }, + ] + : []), ] export default function AppLayout() { + const openAbout = useCallback(() => { + window.open(ABOUT_SIMPLICITY_URL, '_blank', 'noopener,noreferrer') + }, []) + return ( -
-
-
-

Lending

-
- - - -
-

Network: {env.VITE_NETWORK}

-

API URL: {env.VITE_API_URL}

-

Esplora Base URL: {env.VITE_ESPLORA_BASE_URL}

+
+

Network: {env.VITE_NETWORK}

+

API URL: {env.VITE_API_URL}

+

Esplora Base URL: {env.VITE_ESPLORA_BASE_URL}

+
diff --git a/web-v2/src/components/WalletButton.tsx b/web-v2/src/components/WalletButton.tsx new file mode 100644 index 0000000..5294b00 --- /dev/null +++ b/web-v2/src/components/WalletButton.tsx @@ -0,0 +1,18 @@ +import { UiButton } from '@/components/ui/UiButton' +import { DEFAULT_WALLET_TYPE } from '@/lib/wallet-core/types' +import { useWallet } from '@/providers/wallet/useWallet' +import { truncateAddress } from '@/utils/format' + +export function WalletButton() { + const { connectionStatus, receiveAddress, connect } = useWallet() + + if (connectionStatus === 'ready' && receiveAddress) { + return {truncateAddress(receiveAddress)} + } + + return ( + connect(DEFAULT_WALLET_TYPE)}> + Connect Wallet + + ) +} diff --git a/web-v2/src/components/icons/ArrowSquareOutIcon.tsx b/web-v2/src/components/icons/ArrowSquareOutIcon.tsx new file mode 100644 index 0000000..02c886b --- /dev/null +++ b/web-v2/src/components/icons/ArrowSquareOutIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function ArrowSquareOutIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/ArrowSquareUpIcon.tsx b/web-v2/src/components/icons/ArrowSquareUpIcon.tsx new file mode 100644 index 0000000..c322322 --- /dev/null +++ b/web-v2/src/components/icons/ArrowSquareUpIcon.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react' + +export default function ArrowSquareUpIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/ArrowsRotateIcon.tsx b/web-v2/src/components/icons/ArrowsRotateIcon.tsx new file mode 100644 index 0000000..a8ed7bd --- /dev/null +++ b/web-v2/src/components/icons/ArrowsRotateIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function ArrowsRotateIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/BellIcon.tsx b/web-v2/src/components/icons/BellIcon.tsx new file mode 100644 index 0000000..db7d6b3 --- /dev/null +++ b/web-v2/src/components/icons/BellIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function BellIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/ChevronDownIcon.tsx b/web-v2/src/components/icons/ChevronDownIcon.tsx new file mode 100644 index 0000000..651fef5 --- /dev/null +++ b/web-v2/src/components/icons/ChevronDownIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function ChevronDownIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/ChevronsExpandVerticalIcon.tsx b/web-v2/src/components/icons/ChevronsExpandVerticalIcon.tsx new file mode 100644 index 0000000..a08684c --- /dev/null +++ b/web-v2/src/components/icons/ChevronsExpandVerticalIcon.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from 'react' + +export default function ChevronsExpandVerticalIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/CircleDashedIcon.tsx b/web-v2/src/components/icons/CircleDashedIcon.tsx new file mode 100644 index 0000000..2b24b8a --- /dev/null +++ b/web-v2/src/components/icons/CircleDashedIcon.tsx @@ -0,0 +1,25 @@ +import type { SVGProps } from 'react' + +export default function CircleDashedIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/LbtcIcon.tsx b/web-v2/src/components/icons/LbtcIcon.tsx new file mode 100644 index 0000000..fc05e57 --- /dev/null +++ b/web-v2/src/components/icons/LbtcIcon.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from 'react' + +// Liquid Bitcoin (LBTC) brand logo. Multi-color, so fills are intentional (not currentColor). +export default function LbtcIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/icons/UsdtIcon.tsx b/web-v2/src/components/icons/UsdtIcon.tsx new file mode 100644 index 0000000..cd84aaf --- /dev/null +++ b/web-v2/src/components/icons/UsdtIcon.tsx @@ -0,0 +1,29 @@ +import type { SVGProps } from 'react' + +// Tether (USDT) brand logo. Multi-color, so fills are intentional (not currentColor). +export default function UsdtIcon(props: SVGProps) { + return ( + + ) +} diff --git a/web-v2/src/components/ui/OfferStatusBadge.tsx b/web-v2/src/components/ui/OfferStatusBadge.tsx new file mode 100644 index 0000000..0bab938 --- /dev/null +++ b/web-v2/src/components/ui/OfferStatusBadge.tsx @@ -0,0 +1,29 @@ +import { Chip } from '@heroui/react' + +import type { OfferStatus } from '@/api/indexer/schemas' +import CircleDashedIcon from '@/components/icons/CircleDashedIcon' + +export type DisplayStatus = OfferStatus | 'expired' + +type ChipColor = 'success' | 'warning' | 'accent' | 'danger' | 'default' + +const STATUS_CHIP: Record = { + active: { color: 'success', label: 'Active' }, + pending: { color: 'warning', label: 'Pending' }, + repaid: { color: 'accent', label: 'Repaid' }, + liquidated: { color: 'danger', label: 'Liquidated' }, + cancelled: { color: 'default', label: 'Cancelled' }, + claimed: { color: 'default', label: 'Claimed' }, + unknown: { color: 'default', label: 'Unknown' }, + expired: { color: 'default', label: 'Expired' }, +} + +export function OfferStatusBadge({ status }: { status: DisplayStatus }) { + const { color, label } = STATUS_CHIP[status] + return ( + + + {label} + + ) +} diff --git a/web-v2/src/components/ui/UiDataRow.tsx b/web-v2/src/components/ui/UiDataRow.tsx new file mode 100644 index 0000000..88d28aa --- /dev/null +++ b/web-v2/src/components/ui/UiDataRow.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from '@heroui/react' +import type { ReactNode } from 'react' + +export function UiDataRows({ children }: { children: ReactNode }) { + return
{children}
+} + +export function UiDataRow({ + label, + value, + isLoading, +}: { + label: string + value: ReactNode + isLoading?: boolean +}) { + return ( +
+ {label} + {isLoading ? : {value}} +
+ ) +} diff --git a/web-v2/src/components/ui/UiPagination.tsx b/web-v2/src/components/ui/UiPagination.tsx new file mode 100644 index 0000000..8a245a9 --- /dev/null +++ b/web-v2/src/components/ui/UiPagination.tsx @@ -0,0 +1,74 @@ +import { Pagination } from '@heroui/react' +import type { ReactNode } from 'react' + +function buildPageList(current: number, total: number): (number | 'ellipsis')[] { + if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1) + if (current <= 3 || current >= total - 2) { + return [1, 2, 3, 'ellipsis', total - 2, total - 1, total] + } + return [1, 'ellipsis', current - 1, current, current + 1, 'ellipsis', total] +} + +interface UiPaginationProps { + currentPage: number + onPageChange: (page: number) => void + summary?: ReactNode + // pageCount = numbered pages; hasNextPage = prev/next only (no total count) + pageCount?: number + hasNextPage?: boolean +} + +export function UiPagination({ + currentPage, + pageCount, + hasNextPage, + onPageChange, + summary, +}: UiPaginationProps) { + const isLastPage = pageCount !== undefined ? currentPage >= pageCount : !hasNextPage + + return ( + + {summary && {summary}} + + + onPageChange(currentPage - 1)} + > + + Previous + + + + {pageCount !== undefined && + buildPageList(currentPage, pageCount).map((p, idx) => + p === 'ellipsis' ? ( + + + + ) : ( + + onPageChange(p)}> + {p} + + + ), + )} + + {pageCount === undefined && ( + + {currentPage} + + )} + + + onPageChange(currentPage + 1)}> + Next + + + + + + ) +} diff --git a/web-v2/src/constants/assets.ts b/web-v2/src/constants/assets.ts new file mode 100644 index 0000000..726a734 --- /dev/null +++ b/web-v2/src/constants/assets.ts @@ -0,0 +1,20 @@ +import type { NetworkName } from '@/constants/env' + +// Asset IDs per network — Liquid mainnet / testnet / regtest. +// Use as `ASSET_ID[env.VITE_NETWORK].LBTC`. +export const ASSET_ID: Record = { + liquid: { + LBTC: '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d', + USDT: 'ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2', + }, + liquidtestnet: { + LBTC: '144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49', + USDT: 'f3d1ec678811398cd2ae277cbe3849c6f6dbd72c74bc542f7c4b11ff0e820958', + }, + regtest: { + LBTC: '5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225', + USDT: '25b17682b0e4f7b0711de7e8ee2e33cd01d65680eed82cce1af84cfbdde30064', + }, +} + +export const ASSET_DECIMALS = { LBTC: 8, USDT: 2 } as const diff --git a/web-v2/src/constants/lending.ts b/web-v2/src/constants/lending.ts new file mode 100644 index 0000000..8748f03 --- /dev/null +++ b/web-v2/src/constants/lending.ts @@ -0,0 +1,9 @@ +// Liquid ~1 block/minute → 144 blocks ≈ 2.4h. +// Active loans whose term left is under this threshold are "nearing deadline". +export const REPAYMENT_DUE_THRESHOLD_BLOCKS = 144 + +// Dashboard data refresh interval. +export const DASHBOARD_REFETCH_INTERVAL_MS = 30_000 + +// Pagination. +export const TABLE_PAGE_SIZE = 10 diff --git a/web-v2/src/constants/routes.ts b/web-v2/src/constants/routes.ts index 9f02327..644c03c 100644 --- a/web-v2/src/constants/routes.ts +++ b/web-v2/src/constants/routes.ts @@ -3,6 +3,7 @@ export const RoutePath = { Borrow: '/borrow', Supply: '/supply', DesignSystem: '/design-system', + Demo: '/demo', } as const export type RoutePath = (typeof RoutePath)[keyof typeof RoutePath] diff --git a/web-v2/src/lib/wallet-core/types.ts b/web-v2/src/lib/wallet-core/types.ts index 9457cfb..cedd18d 100644 --- a/web-v2/src/lib/wallet-core/types.ts +++ b/web-v2/src/lib/wallet-core/types.ts @@ -1,5 +1,8 @@ export type WalletType = 'Wpkh' | 'ShWpkh' +/** Default wallet type used when kicking off the connect flow. */ +export const DEFAULT_WALLET_TYPE: WalletType = 'Wpkh' + /** Raw JADE_STATE values from getVersion() */ export type JadeConnectionState = 'LOCKED' | 'READY' | 'UNINIT' | 'TEMP' diff --git a/web-v2/src/pages/Dashboard/BalanceCard.tsx b/web-v2/src/pages/Dashboard/BalanceCard.tsx new file mode 100644 index 0000000..fd7bfd5 --- /dev/null +++ b/web-v2/src/pages/Dashboard/BalanceCard.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react' + +import { WalletButton } from '@/components/WalletButton' + +import { CardError, CardHeader, CardShell } from './BaseCard' + +export function BalanceCard({ + icon, + title, + subtitle, + isLoading, + isReady, + error, + connectMessage, + errorMessage, + onRetry, + balance, + fiat, + unsupported, + unsupportedMessage, + children, +}: { + icon: ReactNode + title: string + subtitle: string + isLoading: boolean + isReady: boolean + error: Error | null + connectMessage: string + errorMessage: string + onRetry: () => void + balance: ReactNode + fiat?: string + unsupported?: boolean + unsupportedMessage?: string + children: ReactNode +}) { + const showBody = isReady && !error && !unsupported + return ( + + + {!isReady ? ( +
+

{connectMessage}

+ +
+ ) : unsupported ? ( +
+

{unsupportedMessage}

+
+ ) : error ? ( + + ) : ( + children + )} +
+ ) +} diff --git a/web-v2/src/pages/Dashboard/BaseCard.tsx b/web-v2/src/pages/Dashboard/BaseCard.tsx new file mode 100644 index 0000000..8b2bcd5 --- /dev/null +++ b/web-v2/src/pages/Dashboard/BaseCard.tsx @@ -0,0 +1,109 @@ +import { Skeleton } from '@heroui/react' +import type { ReactNode } from 'react' + +import LbtcIcon from '@/components/icons/LbtcIcon' +import UsdtIcon from '@/components/icons/UsdtIcon' +import { UiButton } from '@/components/ui/UiButton' + +export function CardShell({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +const UNIT_LOGO: Record = { + LBTC: , + USDT: , +} + +export function AssetAmount({ value, unit }: { value: string; unit: string }) { + return ( + <> + {value} + + {UNIT_LOGO[unit]} + {unit} + + + ) +} + +export function CardHeader({ + icon, + title, + subtitle, + balance, + fiat, + isLoading, +}: { + icon: ReactNode + title: string + subtitle: string + balance: ReactNode + fiat?: string + isLoading?: boolean +}) { + return ( +
+
+ {icon} +

{title}

+
+

{subtitle}

+ {isLoading ? ( + + ) : ( +

{balance}

+ )} + {!isLoading && fiat &&

{fiat}

} +
+ ) +} + +export function CardAlert({ + variant, + title, + description, + actionLabel, + onAction, + isDisabled, +}: { + variant: 'warning' | 'accent' + title: string + description: string + actionLabel: string + onAction?: () => void + isDisabled?: boolean +}) { + const titleColor = variant === 'warning' ? 'text-warning' : 'text-foreground' + return ( +
+
+

{title}

+

{description}

+
+ + {actionLabel} + +
+ ) +} + +export function CardError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+

{message}

+ + Retry + +
+ ) +} diff --git a/web-v2/src/pages/Dashboard/BorrowCard.tsx b/web-v2/src/pages/Dashboard/BorrowCard.tsx new file mode 100644 index 0000000..b6d9c4f --- /dev/null +++ b/web-v2/src/pages/Dashboard/BorrowCard.tsx @@ -0,0 +1,79 @@ +import { useNavigate } from 'react-router-dom' + +import CoinsIcon from '@/components/icons/CoinsIcon' +import { UiButton } from '@/components/ui/UiButton' +import { UiDataRow, UiDataRows } from '@/components/ui/UiDataRow' +import { ASSET_DECIMALS } from '@/constants/assets' +import { RoutePath } from '@/constants/routes' +import { formatAsset, truncateAddress } from '@/utils/format' + +import { BalanceCard } from './BalanceCard' +import { AssetAmount, CardAlert } from './BaseCard' +import type { DashboardBorrows } from './useDashboard' + +interface BorrowCardProps { + data: DashboardBorrows + isLoading: boolean + isReady: boolean + onRetry: () => void +} + +export function BorrowCard({ data, isLoading, isReady, onRetry }: BorrowCardProps) { + const navigate = useNavigate() + const { stats, nearExpiryOffers } = data + const alertOffer = nearExpiryOffers[0] + + return ( + } + title='Your Borrows' + subtitle='Complete Balance LBTC' + isLoading={isLoading} + isReady={isReady} + error={data.error} + connectMessage='Connect your wallet to view your borrows.' + errorMessage='Failed to load your borrows.' + unsupported={data.unsupported} + unsupportedMessage="This wallet can't expose a borrower key, so your borrows can't be shown." + onRetry={onRetry} + balance={} + > + + + + + + + + {alertOffer && ( + + )} + + navigate(RoutePath.Borrow)}> + Borrow + + + ) +} diff --git a/web-v2/src/pages/Dashboard/OffersTable.tsx b/web-v2/src/pages/Dashboard/OffersTable.tsx new file mode 100644 index 0000000..62d182f --- /dev/null +++ b/web-v2/src/pages/Dashboard/OffersTable.tsx @@ -0,0 +1,205 @@ +import { Skeleton, Table } from '@heroui/react' +import { keepPreviousData } from '@tanstack/react-query' +import { useMemo, useState } from 'react' + +import { useBlockHeight } from '@/api/esplora/hooks' +import { useOffers } from '@/api/indexer/hooks' +import type { OfferShort } from '@/api/indexer/schemas' +import ArrowsRotateIcon from '@/components/icons/ArrowsRotateIcon' +import ChevronDownIcon from '@/components/icons/ChevronDownIcon' +import ChevronsExpandVerticalIcon from '@/components/icons/ChevronsExpandVerticalIcon' +import { OfferStatusBadge } from '@/components/ui/OfferStatusBadge' +import { UiButton } from '@/components/ui/UiButton' +import { UiPagination } from '@/components/ui/UiPagination' +import { ASSET_DECIMALS } from '@/constants/assets' +import { DASHBOARD_REFETCH_INTERVAL_MS, TABLE_PAGE_SIZE } from '@/constants/lending' +import { formatAsset, formatTermLeft } from '@/utils/format' +import { bpsToPercent, type DisplayOffer, toDisplayOffer } from '@/utils/lending' + +type SortCol = 'collateral_amount' | 'principal_amount' | 'earn' | 'interest_rate' | 'termLeft' +type SortState = { col: SortCol; dir: 'asc' | 'desc' } | null + +// UI sort columns → API fields (termLeft sorts by expiration height). +const SORT_FIELD: Record = { + collateral_amount: 'collateral_amount', + principal_amount: 'principal_amount', + earn: 'earn', + interest_rate: 'interest_rate', + termLeft: 'loan_expiration_time', +} + +function SortableHeader({ + label, + col, + sort, + onSort, +}: { + label: string + col: SortCol + sort: SortState + onSort: (col: SortCol) => void +}) { + const active = sort?.col === col + return ( + + ) +} + +export function OffersTable() { + const [sort, setSort] = useState(null) + const [page, setPage] = useState(1) + + const offset = (page - 1) * TABLE_PAGE_SIZE + + const offersQuery = useOffers( + { + limit: TABLE_PAGE_SIZE + 1, + offset, + sortBy: sort ? SORT_FIELD[sort.col] : undefined, + sortDir: sort?.dir, + }, + { refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS, placeholderData: keepPreviousData }, + ) + const blockHeightQuery = useBlockHeight(DASHBOARD_REFETCH_INTERVAL_MS) + const currentBlockHeight = blockHeightQuery.data ?? 0 + + const rawBatch: OfferShort[] = offersQuery.data ?? [] + const hasNextPage = rawBatch.length > TABLE_PAGE_SIZE + + const displayOffers = useMemo( + () => + (offersQuery.data ?? []) + .slice(0, TABLE_PAGE_SIZE) + .map(o => toDisplayOffer(o, currentBlockHeight)), + [offersQuery.data, currentBlockHeight], + ) + + // Sort is server-side (refetches from offset 0) — reset to page 1. + const handleSort = (col: SortCol) => { + setSort(prev => + prev?.col === col ? { col, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { col, dir: 'asc' }, + ) + setPage(1) + } + + const isLoading = offersQuery.isLoading || blockHeightQuery.isLoading + const isFetching = offersQuery.isFetching || blockHeightQuery.isFetching + const error = (offersQuery.error ?? blockHeightQuery.error) as Error | null + const handleRetry = () => { + void offersQuery.refetch() + void blockHeightQuery.refetch() + } + + return ( +
+
+ +

Most recent Borrow Offers

+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : error ? ( +
+

{error.message || 'Failed to load offers.'}

+ + Retry + +
+ ) : displayOffers.length === 0 ? ( +

No offers found

+ ) : ( + + + + + + + + + + + + + + + + + + + + Status + + + {offer => ( + + + {formatAsset(offer.collateral_amount, ASSET_DECIMALS.LBTC)} + + + {formatAsset(offer.principal_amount, ASSET_DECIMALS.USDT)} + + {formatAsset(offer.earn, ASSET_DECIMALS.USDT)} + {bpsToPercent(offer.interest_rate)} + {formatTermLeft(offer.termLeft)} + + + + + )} + + + + + + +
+ )} +
+ ) +} diff --git a/web-v2/src/pages/Dashboard/OverviewStats.tsx b/web-v2/src/pages/Dashboard/OverviewStats.tsx new file mode 100644 index 0000000..6ba484d --- /dev/null +++ b/web-v2/src/pages/Dashboard/OverviewStats.tsx @@ -0,0 +1,68 @@ +import { Skeleton } from '@heroui/react' +import { useMemo } from 'react' + +import { ASSET_DECIMALS } from '@/constants/assets' +import { formatAsset } from '@/utils/format' +import { bpsToPercent } from '@/utils/lending' + +import { AssetAmount } from './BaseCard' +import type { DashboardOverview } from './useDashboard' + +interface OverviewStatsProps { + data: DashboardOverview + isLoading: boolean +} + +interface OverviewStat { + label: string + value: string + unit?: string + fiat?: string +} + +const GRID = 'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 lg:gap-6' + +function Tile({ label, value, unit, fiat, isLoading }: OverviewStat & { isLoading?: boolean }) { + return ( +
+

{label}

+ {isLoading ? ( + + ) : ( +
+

{unit ? : value}

+ {fiat &&

{fiat}

} +
+ )} +
+ ) +} + +export function OverviewStats({ data, isLoading }: OverviewStatsProps) { + const stats = useMemo( + () => [ + { + label: 'Collateral Locked', + value: formatAsset(data.totalCollateral, ASSET_DECIMALS.LBTC), + unit: 'LBTC', + }, + { + label: 'Borrowings', + value: formatAsset(data.totalBorrowings, ASSET_DECIMALS.USDT), + unit: 'USDT', + }, + { label: 'Average APR', value: bpsToPercent(data.avgApr) }, + { label: 'Active Loans', value: String(data.activeLoans) }, + { label: 'Pending Offers', value: String(data.pendingOffers) }, + ], + [data], + ) + + return ( +
+ {stats.map(stat => ( + + ))} +
+ ) +} diff --git a/web-v2/src/pages/Dashboard/SupplyCard.tsx b/web-v2/src/pages/Dashboard/SupplyCard.tsx new file mode 100644 index 0000000..739ce5f --- /dev/null +++ b/web-v2/src/pages/Dashboard/SupplyCard.tsx @@ -0,0 +1,77 @@ +import { useNavigate } from 'react-router-dom' + +import ArrowSquareUpIcon from '@/components/icons/ArrowSquareUpIcon' +import { UiButton } from '@/components/ui/UiButton' +import { UiDataRow, UiDataRows } from '@/components/ui/UiDataRow' +import { ASSET_DECIMALS } from '@/constants/assets' +import { RoutePath } from '@/constants/routes' +import { formatAsset, truncateAddress } from '@/utils/format' + +import { BalanceCard } from './BalanceCard' +import { AssetAmount, CardAlert } from './BaseCard' +import type { DashboardSupply } from './useDashboard' + +interface SupplyCardProps { + data: DashboardSupply + isLoading: boolean + isReady: boolean + onRetry: () => void +} + +export function SupplyCard({ data, isLoading, isReady, onRetry }: SupplyCardProps) { + const navigate = useNavigate() + const { stats, claimableOffers } = data + const alertOffer = claimableOffers[0] + + return ( + } + title='Your Supply' + subtitle='Complete Balance USDT' + isLoading={isLoading} + isReady={isReady} + error={data.error} + connectMessage='Connect your wallet to view your supply.' + errorMessage='Failed to load your supply.' + onRetry={onRetry} + balance={} + > + + + + + + + + {alertOffer && ( + + )} + + navigate(RoutePath.Supply)}> + Supply + + + ) +} diff --git a/web-v2/src/pages/Dashboard/index.tsx b/web-v2/src/pages/Dashboard/index.tsx index db1e3ac..75e135a 100644 --- a/web-v2/src/pages/Dashboard/index.tsx +++ b/web-v2/src/pages/Dashboard/index.tsx @@ -1,16 +1,35 @@ -import CreateBorrowerAccountDemo from './Demos/CreateBorrowerAccountDemo' -import CreateOfferDemo from './Demos/CreateOfferDemo' -import ScriptAuthCovenantDemo from './Demos/ScriptAuthCovenantDemo' -import { WalletDemo } from './Demos/WalletDemo' +import { BorrowCard } from './BorrowCard' +import { OffersTable } from './OffersTable' +import { OverviewStats } from './OverviewStats' +import { SupplyCard } from './SupplyCard' +import { useDashboard } from './useDashboard' export default function DashboardPage() { + const { overview, borrows, supply, isLoading, isReady, refetch } = useDashboard() + return ( -
-

Dashboard

- - - - +
+
+

General Overview

+ +
+ +
+ + +
+ +
) } diff --git a/web-v2/src/pages/Dashboard/useDashboard.ts b/web-v2/src/pages/Dashboard/useDashboard.ts new file mode 100644 index 0000000..7dfa695 --- /dev/null +++ b/web-v2/src/pages/Dashboard/useDashboard.ts @@ -0,0 +1,205 @@ +import { useCallback, useMemo } from 'react' + +import { useBlockHeight } from '@/api/esplora/hooks' +import { + useOfferIdsByBorrowerPubkey, + useOfferIdsByScript, + useOffers, + useOffersBatch, +} from '@/api/indexer/hooks' +import { ASSET_ID } from '@/constants/assets' +import { env } from '@/constants/env' +import { DASHBOARD_REFETCH_INTERVAL_MS, REPAYMENT_DUE_THRESHOLD_BLOCKS } from '@/constants/lending' +import { useWallet } from '@/providers/wallet/useWallet' +import { getAssetBalance } from '@/utils/balance' +import { type DisplayOffer, toDisplayOffer } from '@/utils/lending' + +const NETWORK_ASSETS = ASSET_ID[env.VITE_NETWORK] + +export type { DisplayOffer } + +export interface DashboardOverview { + totalCollateral: bigint + totalBorrowings: bigint + avgApr: number + activeLoans: number + pendingOffers: number +} + +export interface BorrowStats { + lockedCollateral: bigint + borrowings: bigint + activeLoans: number + pendingOffers: number + toRepay: number +} + +export interface SupplyStats { + suppliedLoans: bigint // total principal_amount across all user's supply offers (USDT) + interestOutstanding: bigint + activeLoans: number + repaidToClaim: number +} + +export interface DashboardBorrows { + balance: bigint + stats: BorrowStats + nearExpiryOffers: DisplayOffer[] + isLoading: boolean + error: Error | null + // Wallet can't expose an x-only pubkey → borrows can't be looked up (≠ "no borrows"). + unsupported: boolean +} + +export interface DashboardSupply { + balance: bigint + stats: SupplyStats + claimableOffers: DisplayOffer[] + isLoading: boolean + error: Error | null +} + +export function useDashboard() { + const { connectionStatus, balances, xOnlyPubkey, scriptPubkey } = useWallet() + const isReady = connectionStatus === 'ready' + + const poll = { refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS } + + const offersQuery = useOffers({}, poll) + const borrowerIdsQuery = useOfferIdsByBorrowerPubkey(xOnlyPubkey ?? '', poll) + const supplyIdsQuery = useOfferIdsByScript(scriptPubkey ?? '', poll) + const blockHeightQuery = useBlockHeight(DASHBOARD_REFETCH_INTERVAL_MS) + + const currentBlockHeight = blockHeightQuery.data ?? 0 + + // Overview is computed from the first offers page (see FIXME below). + const displayOffers = useMemo( + () => (offersQuery.data ?? []).map(offer => toDisplayOffer(offer, currentBlockHeight)), + [offersQuery.data, currentBlockHeight], + ) + + // The user's own offers are resolved by exact id (batch), not joined against + // the offers page — otherwise any offer outside page 1 would be silently dropped. + const borrowerOffersQuery = useOffersBatch(borrowerIdsQuery.data ?? [], poll) + const supplyOffersQuery = useOffersBatch(supplyIdsQuery.data ?? [], poll) + + const borrowerOffers = useMemo( + () => (borrowerOffersQuery.data ?? []).map(o => toDisplayOffer(o, currentBlockHeight)), + [borrowerOffersQuery.data, currentBlockHeight], + ) + + const supplyOffers = useMemo( + () => (supplyOffersQuery.data ?? []).map(o => toDisplayOffer(o, currentBlockHeight)), + [supplyOffersQuery.data, currentBlockHeight], + ) + + // FIXME(backend): computed over one page (`useOffers({})`), not all offers — totals + // are approximate. Needs a server-side aggregate endpoint (GET /offers/stats). + const overview = useMemo(() => { + const active = displayOffers.filter(o => o.status === 'active') + const totalCollateral = active.reduce((acc, o) => acc + o.collateral_amount, 0n) + const totalBorrowings = active.reduce((acc, o) => acc + o.principal_amount, 0n) + const avgApr = active.length + ? active.reduce((acc, o) => acc + o.interest_rate, 0) / active.length + : 0 + return { + totalCollateral, + totalBorrowings, + avgApr, + activeLoans: active.length, + pendingOffers: displayOffers.filter(o => o.status === 'pending').length, + } + }, [displayOffers]) + + const lbtcBalance = getAssetBalance(balances, NETWORK_ASSETS.LBTC) + const usdtBalance = getAssetBalance(balances, NETWORK_ASSETS.USDT) + + const borrows = useMemo(() => { + const active = borrowerOffers.filter(o => o.status === 'active') + const pending = borrowerOffers.filter(o => o.status === 'pending') + const nearExpiryOffers = active.filter( + o => o.termLeft > 0 && o.termLeft < REPAYMENT_DUE_THRESHOLD_BLOCKS, + ) + return { + balance: lbtcBalance, + stats: { + lockedCollateral: active.reduce((acc, o) => acc + o.collateral_amount, 0n), + borrowings: active.reduce((acc, o) => acc + o.principal_amount, 0n), + activeLoans: active.length, + pendingOffers: pending.length, + toRepay: nearExpiryOffers.length, + }, + nearExpiryOffers, + isLoading: isReady && (borrowerIdsQuery.isLoading || borrowerOffersQuery.isLoading), + error: borrowerIdsQuery.error ?? borrowerOffersQuery.error, + unsupported: isReady && !xOnlyPubkey, + } + }, [ + isReady, + xOnlyPubkey, + lbtcBalance, + borrowerOffers, + borrowerIdsQuery.isLoading, + borrowerIdsQuery.error, + borrowerOffersQuery.isLoading, + borrowerOffersQuery.error, + ]) + + const supply = useMemo(() => { + const active = supplyOffers.filter(o => o.status === 'active') + const claimableOffers = supplyOffers.filter(o => o.status === 'repaid') + return { + balance: usdtBalance, + stats: { + suppliedLoans: supplyOffers.reduce((acc, o) => acc + o.principal_amount, 0n), + interestOutstanding: active.reduce((acc, o) => acc + o.earn, 0n), + activeLoans: active.length, + repaidToClaim: claimableOffers.length, + }, + claimableOffers, + isLoading: isReady && (supplyIdsQuery.isLoading || supplyOffersQuery.isLoading), + error: supplyIdsQuery.error ?? supplyOffersQuery.error, + } + }, [ + isReady, + usdtBalance, + supplyOffers, + supplyIdsQuery.isLoading, + supplyIdsQuery.error, + supplyOffersQuery.isLoading, + supplyOffersQuery.error, + ]) + + const offersRefetch = offersQuery.refetch + const borrowerIdsRefetch = borrowerIdsQuery.refetch + const supplyIdsRefetch = supplyIdsQuery.refetch + const borrowerOffersRefetch = borrowerOffersQuery.refetch + const supplyOffersRefetch = supplyOffersQuery.refetch + const blockHeightRefetch = blockHeightQuery.refetch + + const refetch = useCallback(() => { + void offersRefetch() + void borrowerIdsRefetch() + void supplyIdsRefetch() + void borrowerOffersRefetch() + void supplyOffersRefetch() + void blockHeightRefetch() + }, [ + offersRefetch, + borrowerIdsRefetch, + supplyIdsRefetch, + borrowerOffersRefetch, + supplyOffersRefetch, + blockHeightRefetch, + ]) + + return { + overview, + borrows, + supply, + isReady, + // Overview is public — must not gate loading on isReady (else flashes zeros). + isLoading: offersQuery.isLoading || blockHeightQuery.isLoading, + refetch, + } +} diff --git a/web-v2/src/pages/Dashboard/Demos/CreateBorrowerAccountDemo.tsx b/web-v2/src/pages/Demo/CreateBorrowerAccountDemo.tsx similarity index 100% rename from web-v2/src/pages/Dashboard/Demos/CreateBorrowerAccountDemo.tsx rename to web-v2/src/pages/Demo/CreateBorrowerAccountDemo.tsx diff --git a/web-v2/src/pages/Dashboard/Demos/CreateOfferDemo.tsx b/web-v2/src/pages/Demo/CreateOfferDemo.tsx similarity index 100% rename from web-v2/src/pages/Dashboard/Demos/CreateOfferDemo.tsx rename to web-v2/src/pages/Demo/CreateOfferDemo.tsx diff --git a/web-v2/src/pages/Dashboard/Demos/ScriptAuthCovenantDemo.tsx b/web-v2/src/pages/Demo/ScriptAuthCovenantDemo.tsx similarity index 99% rename from web-v2/src/pages/Dashboard/Demos/ScriptAuthCovenantDemo.tsx rename to web-v2/src/pages/Demo/ScriptAuthCovenantDemo.tsx index 6f727f4..9aaa08d 100644 --- a/web-v2/src/pages/Dashboard/Demos/ScriptAuthCovenantDemo.tsx +++ b/web-v2/src/pages/Demo/ScriptAuthCovenantDemo.tsx @@ -22,7 +22,7 @@ import { saveScriptAuthState, selectDemoScriptAuthInputs, useTxConfirmations, -} from '@/pages/Dashboard/Demos/helpers' +} from '@/pages/Demo/helpers' import { useLwk } from '@/providers/lwk/useLwk' import { useWallet } from '@/providers/wallet/useWallet' import { buildScriptAuthWitness, loadScriptAuthProgram } from '@/simplicity/script-auth/program' diff --git a/web-v2/src/pages/Dashboard/Demos/TxResult.tsx b/web-v2/src/pages/Demo/TxResult.tsx similarity index 100% rename from web-v2/src/pages/Dashboard/Demos/TxResult.tsx rename to web-v2/src/pages/Demo/TxResult.tsx diff --git a/web-v2/src/pages/Dashboard/Demos/WalletDemo.tsx b/web-v2/src/pages/Demo/WalletDemo.tsx similarity index 100% rename from web-v2/src/pages/Dashboard/Demos/WalletDemo.tsx rename to web-v2/src/pages/Demo/WalletDemo.tsx diff --git a/web-v2/src/pages/Dashboard/Demos/helpers.ts b/web-v2/src/pages/Demo/helpers.ts similarity index 100% rename from web-v2/src/pages/Dashboard/Demos/helpers.ts rename to web-v2/src/pages/Demo/helpers.ts diff --git a/web-v2/src/pages/Demo/index.tsx b/web-v2/src/pages/Demo/index.tsx new file mode 100644 index 0000000..6521596 --- /dev/null +++ b/web-v2/src/pages/Demo/index.tsx @@ -0,0 +1,16 @@ +import CreateBorrowerAccountDemo from './CreateBorrowerAccountDemo' +import CreateOfferDemo from './CreateOfferDemo' +import ScriptAuthCovenantDemo from './ScriptAuthCovenantDemo' +import { WalletDemo } from './WalletDemo' + +export default function DemoPage() { + return ( +
+

Demo

+ + + + +
+ ) +} diff --git a/web-v2/src/providers/wallet/WalletProvider.tsx b/web-v2/src/providers/wallet/WalletProvider.tsx index 0f47751..ad06d57 100644 --- a/web-v2/src/providers/wallet/WalletProvider.tsx +++ b/web-v2/src/providers/wallet/WalletProvider.tsx @@ -181,6 +181,11 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const balances = await syncBalances(wollet, esploraClient) + const address = wollet.address().address() + const receiveAddress = address.toString() + const scriptPubkey = address.scriptPubkey().toString() + const xOnlyPubkey = (await connector.getXOnlyPublicKey?.())?.toString() ?? null + setState(s => ({ ...s, connectionStatus: 'ready', @@ -188,6 +193,9 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { error: null, isError: false, balances, + receiveAddress, + scriptPubkey, + xOnlyPubkey, })) } catch (err) { const error = err instanceof Error ? err.message : String(err) diff --git a/web-v2/src/providers/wallet/types.ts b/web-v2/src/providers/wallet/types.ts index f279040..7cbf8dd 100644 --- a/web-v2/src/providers/wallet/types.ts +++ b/web-v2/src/providers/wallet/types.ts @@ -40,6 +40,10 @@ export interface WalletState { connectorId: string | null walletType: WalletType | null balances: Record + // Resolved once on connect; null until ready. + receiveAddress: string | null + scriptPubkey: string | null + xOnlyPubkey: string | null syncing: boolean usbDeviceDetected: boolean /** Last error message. Persists even after isError is cleared. */ @@ -53,6 +57,9 @@ export const INITIAL_WALLET_STATE: WalletState = { connectorId: null, walletType: null, balances: {}, + receiveAddress: null, + scriptPubkey: null, + xOnlyPubkey: null, syncing: false, usbDeviceDetected: false, error: null, diff --git a/web-v2/src/utils/balance.ts b/web-v2/src/utils/balance.ts new file mode 100644 index 0000000..b0f099d --- /dev/null +++ b/web-v2/src/utils/balance.ts @@ -0,0 +1,5 @@ +// Wallet balances arrive as asset id → satoshis string. 0 for missing. +export function getAssetBalance(balances: Record, assetId: string): bigint { + const raw = balances[assetId] + return raw ? BigInt(raw) : 0n +} diff --git a/web-v2/src/utils/format.ts b/web-v2/src/utils/format.ts new file mode 100644 index 0000000..57949d7 --- /dev/null +++ b/web-v2/src/utils/format.ts @@ -0,0 +1,35 @@ +const MINUTES_PER_BLOCK = 1 // Liquid ~1 min/block +const MINUTES_PER_HOUR = 60 +const MINUTES_PER_DAY = 1440 + +// satoshis → grouped decimal string, trailing zeros trimmed. +export function formatAsset(amount: bigint, decimals: number): string { + const negative = amount < 0n + const abs = negative ? -amount : amount + const base = 10n ** BigInt(decimals) + const whole = abs / base + const frac = abs % base + + const wholeStr = whole.toLocaleString('en-US') + const fracStr = frac.toString().padStart(decimals, '0').replace(/0+$/, '') + + const out = fracStr ? `${wholeStr}.${fracStr}` : wholeStr + return negative ? `-${out}` : out +} + +// blocks remaining → "Expired" / "~Xm" / "~Xh" / ">Xd". +export function formatTermLeft(blocksLeft: number): string { + if (blocksLeft <= 0) return 'Expired' + const minutes = blocksLeft * MINUTES_PER_BLOCK + if (minutes < MINUTES_PER_HOUR) return `~${minutes}m` + if (minutes < MINUTES_PER_DAY) return `~${Math.round(minutes / MINUTES_PER_HOUR)}h` + return `>${Math.floor(minutes / MINUTES_PER_DAY)}d` +} + +// TODO(oracle): add fiat conversion (LBTC/USDT → USD). `fiat` props left unset +// until then — a "$0.00" placeholder would read as a real zero balance. + +export function truncateAddress(address: string): string { + if (address.length <= 10) return address + return `${address.slice(0, 6)}...${address.slice(-4)}` +} diff --git a/web-v2/src/utils/lending.ts b/web-v2/src/utils/lending.ts new file mode 100644 index 0000000..b58d81a --- /dev/null +++ b/web-v2/src/utils/lending.ts @@ -0,0 +1,31 @@ +import type { OfferShort } from '@/api/indexer/schemas' +import type { DisplayStatus } from '@/components/ui/OfferStatusBadge' + +// Interest in satoshis. bps = basis points (1000 = 10%, 10000 = 100%). +export function calcInterest(principal: bigint, bps: number): bigint { + return (principal * BigInt(Math.round(bps))) / 10_000n +} + +// Display basis points as human-readable percent string: 1000 → "10.00%" +export function bpsToPercent(bps: number): string { + return `${(bps / 100).toFixed(2)}%` +} + +// Offer enriched with values derived from the current chain tip, ready for display. +export interface DisplayOffer extends OfferShort { + termLeft: number + displayStatus: DisplayStatus + earn: bigint // total interest, precomputed for sorting +} + +// Single source of truth for offer → display mapping (used by dashboard + table). +export function toDisplayOffer(offer: OfferShort, currentBlockHeight: number): DisplayOffer { + const termLeft = offer.loan_expiration_time - currentBlockHeight + const displayStatus = offer.status === 'pending' && termLeft <= 0 ? 'expired' : offer.status + return { + ...offer, + termLeft, + displayStatus, + earn: calcInterest(offer.principal_amount, offer.interest_rate), + } +}