-
Dashboard
-
-
-
-
+
)
}
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),
+ }
+}