diff --git a/README.md b/README.md index 7752ccaea..73869399f 100644 --- a/README.md +++ b/README.md @@ -184,12 +184,18 @@ invofi/ │ │ ├── invoices/ InvoiceCard, InvoiceForm, InvoiceTable, │ │ │ OfferList │ │ ├── layout/ Navbar (dark mode + a11y), Footer +│ │ ├── marketplace/ MarketplaceCard, PositionListingCard, etc. +│ │ ├── portfolio/ LivePortfolioProvider, ConnectionStatus, +│ │ │ RepaymentProgress (live dashboard, issue #221) │ │ └── ui/ shadcn/ui — button, dialog, table, │ │ badge, card, input, tabs, toast... │ ├── hooks/ useInvoices, useOffers, useMarketplace, │ │ useLocalStorage, useDebounce, useMediaQuery │ └── lib/ │ ├── contract.ts Soroban contract call helpers (3 contracts) +│ ├── live/ Live portfolio engine (issue #221): WebSocket +│ │ + Soroban-event polling transports, per-position +│ │ throttle, yield/APY math, USD pricing, reducer │ ├── approved-wallets.ts Approved-wallet allowlist (extension point) │ ├── walletkit.ts stellar-wallets-kit init + active-wallet signing │ ├── horizon.ts Stellar Horizon API helpers @@ -475,6 +481,8 @@ create policy "Own profile" on user_profiles for all using (id = auth.uid()); | `NEXT_PUBLIC_STELLAR_NETWORK` | `testnet` | | `NEXT_PUBLIC_RPC_URL` | `https://soroban-testnet.stellar.org` | | `NEXT_PUBLIC_HORIZON_URL` | `https://horizon-testnet.stellar.org` | +| `NEXT_PUBLIC_WS_URL` | *(optional)* WebSocket relay for the live portfolio dashboard — omit to use the polling fallback | +| `NEXT_PUBLIC_XLM_USD_PRICE` | *(optional)* Fallback XLM/USD price for live USD position values | --- @@ -539,6 +547,7 @@ Both identities are auto-funded via Friendbot on testnet. See - [x] Architecture Decision Records — ADR index in both repos - [x] Deployer-bound initialization — `__constructor` on all contracts, no front-runnable `initialize()` (issue #75) - [x] Compliance posture documented — KYC/SEP-12 roadmap, jurisdictions, securities-by-design +- [x] Live portfolio dashboard — WebSocket streaming (position/yield/repayment) with reconnection + polling fallback (issue #221) - [ ] Mainnet deployment - [ ] Oracle-based invoice verification and risk scoring - [ ] Multi-signature treasury and escrow diff --git a/docs/08-environment-variables.md b/docs/08-environment-variables.md index f6778a543..31ea3f51d 100644 --- a/docs/08-environment-variables.md +++ b/docs/08-environment-variables.md @@ -17,6 +17,8 @@ All environment variables for the InvoFi frontend are prefixed with `NEXT_PUBLIC | `NEXT_PUBLIC_RPC_URL` | Yes | See below | Soroban RPC endpoint (differs by network) | | `NEXT_PUBLIC_HORIZON_URL` | Yes | See below | Stellar Horizon REST API (differs by network) | | `NEXT_PUBLIC_USDC_ISSUER` | No | `GBBD47IF...` | USDC issuer address. Required to display USDC balances. | +| `NEXT_PUBLIC_WS_URL` | No | `wss://relay.invofi.dev` | WebSocket relay for the live portfolio dashboard (issue #221). When empty or unreachable the dashboard degrades to Soroban event-stream + Supabase polling. | +| `NEXT_PUBLIC_XLM_USD_PRICE` | No | `0.15` | XLM/USD fallback price used for live USD position values when the live price feed (CoinGecko) is unreachable. | \* Legacy fallback: if the three `*_CONTRACT_ID` variables are unset, the app uses the single `NEXT_PUBLIC_CONTRACT_ID` and routes every call to that one @@ -79,3 +81,5 @@ To use different values for Preview and Production deployments, set the environm | `RPC_URL` | `lib/contract.ts` — Soroban RPC for simulating and sending transactions | | `HORIZON_URL` | `lib/horizon.ts` — reads account balances and transaction history | | `USDC_ISSUER` | `lib/horizon.ts` — identifies the USDC asset when reading balances | +| `WS_URL` | `lib/live/*` — live portfolio dashboard WebSocket relay; falls back to polling | +| `XLM_USD_PRICE` | `lib/live/prices.ts` — fallback XLM/USD price for live USD position values | diff --git a/invofi/apps/frontend/.env.local.example b/invofi/apps/frontend/.env.local.example new file mode 100644 index 000000000..f5d16b89f --- /dev/null +++ b/invofi/apps/frontend/.env.local.example @@ -0,0 +1,28 @@ +# Copy to .env.local and fill in your values. See docs/08-environment-variables.md. + +# ── Supabase (required) ───────────────────────────────────────────────────── +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# ── Stellar network ────────────────────────────────────────────────────────── +NEXT_PUBLIC_STELLAR_NETWORK=testnet +NEXT_PUBLIC_RPC_URL=https://soroban-testnet.stellar.org +NEXT_PUBLIC_HORIZON_URL=https://horizon-testnet.stellar.org +NEXT_PUBLIC_USDC_ISSUER=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + +# ── Protocol contracts (required) ──────────────────────────────────────────── +NEXT_PUBLIC_REGISTRY_CONTRACT_ID= +NEXT_PUBLIC_FINANCING_CONTRACT_ID= +NEXT_PUBLIC_REPAYMENT_CONTRACT_ID= + +# ── Position tokens (optional; default shown) ──────────────────────────────── +NEXT_PUBLIC_POSITION_TOKEN_ASSET=POS:GBDDLOWR6YUEEYUKFKS6ISTCLBQKDPUXAOVJMNJYAACT6UYQGEKYEVZR + +# ── Live portfolio dashboard (optional) ────────────────────────────────────── +# WebSocket relay URL for the live portfolio dashboard (issue #221). When +# empty or unreachable the dashboard degrades to the Soroban event-stream + +# Supabase polling fallback. +NEXT_PUBLIC_WS_URL= + +# XLM/USD fallback price used when the live price feed is unreachable. +NEXT_PUBLIC_XLM_USD_PRICE= \ No newline at end of file diff --git a/invofi/apps/frontend/src/app/portfolio/layout.tsx b/invofi/apps/frontend/src/app/portfolio/layout.tsx index c7a2a7b47..15d734ee9 100644 --- a/invofi/apps/frontend/src/app/portfolio/layout.tsx +++ b/invofi/apps/frontend/src/app/portfolio/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { LivePortfolioProvider } from '@/components/portfolio/LivePortfolioProvider'; export const metadata: Metadata = { title: 'Portfolio', @@ -6,5 +7,9 @@ export const metadata: Metadata = { }; export default function PortfolioLayout({ children }: { children: React.ReactNode }) { - return <>{children}; + return ( + + {children} + + ); } diff --git a/invofi/apps/frontend/src/app/portfolio/page.tsx b/invofi/apps/frontend/src/app/portfolio/page.tsx index 81a8d8f8a..0e41c59c3 100644 --- a/invofi/apps/frontend/src/app/portfolio/page.tsx +++ b/invofi/apps/frontend/src/app/portfolio/page.tsx @@ -3,21 +3,23 @@ import { Suspense, useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { TrendingUp, Clock, CheckCircle2, AlertCircle, Download, Copy, Check, Send, RefreshCw, Tag } from 'lucide-react'; +import { TrendingUp, Clock, CheckCircle2, AlertCircle, Download, Copy, Check, Send, RefreshCw, Tag, DollarSign } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { AuthGuard } from '@/components/auth/AuthGuard'; import { useWallet } from '@/components/auth/WalletProvider'; import { TableSkeleton } from '@/components/common/LoadingSkeleton'; -import { supabase } from '@/lib/supabase'; import { useToast } from '@/components/ui/use-toast'; import { addPositionTrustline, getPositionTokenId, getTokenBalance, getTokenDecimals, hasPositionTrustline, transferPositionToken } from '@/lib/contract'; -import { formatAmount, formatDate, interestRateLabel, durationLabel, toStroopsBigInt, OFFER_STATUS_COLORS } from '@/lib/utils'; +import { formatAmount, formatDate, interestRateLabel, durationLabel, OFFER_STATUS_COLORS } from '@/lib/utils'; import { STROOPS_PER_XLM } from '@/lib/constants'; import { toCsv, downloadCsv } from '@/lib/csv'; -import type { FinancingOffer } from '@/types'; -import { SupabaseUser } from '@/lib/types/supabase-auth'; +import { stroopsToUsd } from '@/lib/live/prices'; +import { useLivePortfolio } from '@/components/portfolio/LivePortfolioProvider'; +import { ConnectionStatus } from '@/components/portfolio/ConnectionStatus'; +import { RepaymentProgress } from '@/components/portfolio/RepaymentProgress'; +import type { LivePosition } from '@/lib/live/types'; const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'; @@ -31,11 +33,6 @@ const STATUS_ICONS = { Defaulted: AlertCircle, } as const; -/** Total repayment due in stroops: principal + simple yield (matches the contract). */ -function offerTotalDue(offer: FinancingOffer): bigint { - return toStroopsBigInt(offer.amount) + (toStroopsBigInt(offer.amount) * BigInt(offer.interest_rate)) / 10_000n; -} - /** Parse a decimal string (e.g. "12.5") into base units for `decimals` places. */ function toBaseUnits(amount: string, decimals: number): bigint | null { if (!/^\d+(\.\d+)?$/.test(amount)) return null; @@ -53,6 +50,14 @@ function isStellarAddress(addr: string): boolean { return /^G[A-Z2-7]{55}$/.test(addr); } +/** Compact "updated Xs ago" for the per-row live timestamp. */ +function relativeUpdate(ts: number): string { + const diff = Date.now() - ts; + if (diff < 1_000) return 'just now'; + if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`; + return `${Math.floor(diff / 60_000)}m ago`; +} + /** * Task 8: transfer a financed-invoice position token to another wallet. * The token is a standard SEP-41 Stellar asset contract minted to the lender @@ -276,53 +281,117 @@ function CopyId({ id }: { id: string }) { ); } -export default function PortfolioPage() { - const [offers, setOffers] = useState([]); - const [loading, setLoading] = useState(true); +/** Live position row: value + yields + streaming repayment progress. */ +function PositionCard({ offer }: { offer: LivePosition }) { + const Icon = STATUS_ICONS[offer.status] ?? Clock; + const active = offer.status === 'Accepted' || offer.status === 'Financed'; + const pct = Math.round(offer.repaymentProgress * 100); - useEffect(() => { - supabase.auth.getUser().then(async ({ data }: { data: { user: SupabaseUser | null } }) => { - const { user } = data; - if (!user) { - // Wallet-only user — no offers to show yet; stop the spinner. - setLoading(false); - return; - } - const { data: offersData } = await supabase - .from('financing_offers') - .select('*, invoice:invoices(*)') - .eq('lender_id', user.id) - .order('created_at', { ascending: false }); - const rows = (offersData as unknown as FinancingOffer[]) ?? []; - // Normalize mirror strings (and contract i128s) to bigint stroops so - // amount/amount_repaid math and display are consistent. - setOffers(rows.map(o => ({ - ...o, - amount: toStroopsBigInt(o.amount), - amount_repaid: toStroopsBigInt(o.amount_repaid), - }))); - setLoading(false); - }); -}, []); + return ( + + +
+
+ +
+
+ + + ↗ + +
+

+ {interestRateLabel(offer.interest_rate)} · {durationLabel(offer.duration)} + {offer.funded_at > 0 && ` · Funded ${formatDate(offer.funded_at)}`} +

+
+
+
+
+

+ {formatAmount(offer.amount)} {offer.currency} +

+

+ ≈ ${offer.liveValueUsd.toFixed(2)} USD +

+
+ {offer.status} +
+
+ + {active && ( + <> +
+
+

APY

+

{offer.apy.toFixed(2)}%

+
+
+

Earned to date

+

+ {formatAmount(offer.earnedToDate)} {offer.currency} + + {' '}≈ ${stroopsToUsd(offer.earnedToDate, offer.currency).toFixed(2)} + +

+
+
+

Repayment

+

{pct}% repaid

+
+
+
+ +

+ {formatAmount(offer.amount_repaid)} repaid · {formatAmount(offer.remaining)} remaining ·{' '} + updated {relativeUpdate(offer.updatedAt)} +

+
+ + )} +
+
+ ); +} + +export default function PortfolioPage() { + const { + positions, + loading, + error, + lastUpdatedAt, + refresh, + } = useLivePortfolio(); // An offer is active while it is financing an invoice: from acceptance until // it is fully repaid. Partial repayments flip offers to Financed on-chain, // so both statuses count as deployed capital. - const active = offers.filter(o => o.status === 'Accepted' || o.status === 'Financed'); - const repaid = offers.filter(o => o.status === 'Repaid'); - const pending = offers.filter(o => o.status === 'Pending'); + const active = positions.filter(o => o.status === 'Accepted' || o.status === 'Financed'); + const repaid = positions.filter(o => o.status === 'Repaid'); + const pending = positions.filter(o => o.status === 'Pending'); - const totalDeployed = active.reduce((sum, o) => sum + Number(o.amount) / STROOPS_PER_XLM, 0); + const totalValueUsd = active.reduce((sum, o) => sum + o.liveValueUsd, 0); + const totalEarnedToDateUsd = active.reduce( + (sum, o) => sum + stroopsToUsd(o.earnedToDate, o.currency), + 0, + ); + // Repaid positions may be in different currencies — never sum raw yields as + // if they were the same asset. Convert each to USD first. const totalEarned = repaid.reduce((sum, o) => { - const principal = Number(o.amount) / STROOPS_PER_XLM; - const yield_ = principal * (o.interest_rate / 10000); - return sum + yield_; + const yield_ = o.totalDue - o.amount; + return sum + stroopsToUsd(yield_, o.currency); }, 0); const exportOffersCsv = () => { - const rows = offers.map(o => ({ + const rows = positions.map(o => ({ ...o, amount: Number(o.amount) / STROOPS_PER_XLM, + amount_repaid: Number(o.amount_repaid) / STROOPS_PER_XLM, funded_at: o.funded_at > 0 ? new Date(o.funded_at * 1000).toISOString().slice(0, 10) : '', })); const csv = toCsv(rows, [ @@ -341,18 +410,36 @@ export default function PortfolioPage() { return (
-
+

Your Portfolio

-

Track your financing offers and returns

+

+ Track your financing offers and returns — updates stream in live + {lastUpdatedAt + ? · updated {relativeUpdate(lastUpdatedAt)} + : null} +

- {offers.length > 0 && ( - - )} + {positions.length > 0 && ( + + )} +
+ {error && ( +
+ +

{error}

+
+ )} + {/* Summary stats */}
@@ -378,22 +465,32 @@ export default function PortfolioPage() { - -

{totalDeployed.toFixed(2)}

-

Total Deployed

+ +

${totalValueUsd.toFixed(2)}

+

Portfolio Value (USD)

- {/* Extra earned stat */} - {repaid.length > 0 && ( + {/* Live earnings strip */} + {(active.length > 0 || repaid.length > 0) && (
-
-

- Est. yield earned: {totalEarned.toFixed(4)} (across {repaid.length} repaid offer{repaid.length !== 1 ? 's' : ''}) -

-

Based on agreed interest rates

+
+
+

+ Est. yield earned to date: ${totalEarnedToDateUsd.toFixed(2)} +

+

Accruing in real time across {active.length} active position{active.length !== 1 ? 's' : ''}

+
+ {repaid.length > 0 && ( +
+

+ Realized yield: ${totalEarned.toFixed(2)} USD +

+

Across {repaid.length} repaid offer{repaid.length !== 1 ? 's' : ''}

+
+ )}
)} @@ -402,7 +499,7 @@ export default function PortfolioPage() { {loading && } {/* Empty state */} - {!loading && offers.length === 0 && ( + {!loading && positions.length === 0 && (

No financing offers yet.

@@ -416,50 +513,9 @@ export default function PortfolioPage() { )}
- {offers.map(offer => { - const Icon = STATUS_ICONS[offer.status] ?? Clock; - return ( - - -
- -
-
- - - ↗ - -
-

- {interestRateLabel(offer.interest_rate)} · {durationLabel(offer.duration)} - {offer.funded_at > 0 && ` · Funded ${formatDate(offer.funded_at)}`} -

- {(offer.status === 'Accepted' || offer.status === 'Financed') && - toStroopsBigInt(offer.amount_repaid) > 0n && ( -

- {formatAmount(toStroopsBigInt(offer.amount_repaid))} repaid ·{' '} - {formatAmount(offerTotalDue(offer) - toStroopsBigInt(offer.amount_repaid))} remaining -

- )} -
-
-
-
-

- {formatAmount(offer.amount)} {offer.currency} -

-
- {offer.status} -
-
-
- ); - })} + {positions.map(offer => ( + + ))}
{/* useSearchParams (the listing hand-off prefill) needs a Suspense boundary. */} @@ -469,4 +525,4 @@ export default function PortfolioPage() {
); -} +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/components/portfolio/ConnectionStatus.tsx b/invofi/apps/frontend/src/components/portfolio/ConnectionStatus.tsx new file mode 100644 index 000000000..94ec553dd --- /dev/null +++ b/invofi/apps/frontend/src/components/portfolio/ConnectionStatus.tsx @@ -0,0 +1,66 @@ +'use client'; + +// ── Live connection status pill (issue #221) ───────────────────────────────── +// Always-visible indicator of the dashboard's data source: +// • green — streaming over WebSocket +// • blue — polling fallback (Soroban events + Supabase resync) +// • amber — connecting / reconnecting (with backoff detail) +// • red — no live source available + +import { useLivePortfolio } from './LivePortfolioProvider'; +import type { ConnectionStatus } from '@/lib/live/types'; +import { cn } from '@/lib/utils'; + +const STATUS_META: Record = { + connected: { + label: 'Live · WebSocket', + dot: 'bg-green-500', + className: + 'border-green-200 bg-green-50 text-green-800 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300', + }, + polling: { + label: 'Live · Polling', + dot: 'bg-blue-500', + className: + 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300', + }, + connecting: { + label: 'Connecting…', + dot: 'bg-amber-500 animate-pulse', + className: + 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300', + }, + reconnecting: { + label: 'Reconnecting…', + dot: 'bg-amber-500 animate-pulse', + className: + 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300', + }, + offline: { + label: 'Offline', + dot: 'bg-red-500', + className: + 'border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300', + }, +}; + +export function ConnectionStatus() { + const { connection, connectionDetail } = useLivePortfolio(); + const meta = STATUS_META[connection]; + + return ( +
+
+ ); +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/components/portfolio/LivePortfolioProvider.tsx b/invofi/apps/frontend/src/components/portfolio/LivePortfolioProvider.tsx new file mode 100644 index 000000000..e1a57973d --- /dev/null +++ b/invofi/apps/frontend/src/components/portfolio/LivePortfolioProvider.tsx @@ -0,0 +1,179 @@ +'use client'; + +// ── Live portfolio provider (issue #221) ───────────────────────────────────── +// React context + useReducer that owns the live dashboard state. It loads the +// lender's offers from the Supabase mirror, subscribes to the live engine +// (WebSocket relay → Soroban event polling fallback), and re-derives every +// position row (USD value, APY, earned-to-date, repayment progress) on each +// update. Scoped to the portfolio route via `app/portfolio/layout.tsx`. + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; +import type { FinancingOffer } from '@invofi/sdk'; +import { useWallet } from '@/components/auth/WalletProvider'; +import { supabase } from '@/lib/supabase'; +import { toStroopsBigInt } from '@/lib/utils'; +import { LivePortfolioEngine } from '@/lib/live/engine'; +import { + INITIAL_LIVE_PORTFOLIO_STATE, + livePortfolioReducer, +} from '@/lib/live/reducer'; +import { + LIVE_CONTRACT_IDS, + LIVE_NETWORK_PASSPHRASE, + LIVE_RPC_URL, + LIVE_WS_URL, +} from '@/lib/live/config'; +import { refreshXlmUsdPrice } from '@/lib/live/prices'; +import type { ConnectionStatus, LivePosition, LiveTransport } from '@/lib/live/types'; + +export interface LivePortfolioContextValue { + positions: LivePosition[]; + connection: ConnectionStatus; + connectionDetail: string | null; + transport: LiveTransport; + loading: boolean; + error: string | null; + lastUpdatedAt: number | null; + /** Force an immediate resync (also refreshes the XLM/USD price). */ + refresh: () => void; +} + +const LivePortfolioContext = createContext(null); + +export function LivePortfolioProvider({ children }: { children: React.ReactNode }) { + const { publicKey } = useWallet(); + const [state, dispatch] = useReducer(livePortfolioReducer, INITIAL_LIVE_PORTFOLIO_STATE); + const engineRef = useRef(null); + + // Restart the stream only when the authenticated user actually changes + // (wallet connect / sign-out), not on every Supabase token refresh. + const [sessionNonce, setSessionNonce] = useState(0); + useEffect(() => { + let cancelled = false; + let lastUserId: string | null = null; + void supabase.auth.getUser().then(({ data }) => { + if (cancelled) return; + lastUserId = data.user?.id ?? null; + }); + const { data: { subscription } } = supabase.auth.onAuthStateChange(() => { + void supabase.auth.getUser().then(({ data }) => { + if (cancelled) return; + const id = data.user?.id ?? null; + if (id !== lastUserId) { + lastUserId = id; + setSessionNonce(nonce => nonce + 1); + } + }); + }); + return () => { + cancelled = true; + subscription.unsubscribe(); + }; + }, []); + + /** Full-state fetch: the lender's offers from the Supabase mirror. */ + const fetchPositions = useCallback(async (): Promise => { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return []; + const { data, error } = await supabase + .from('financing_offers') + .select('*, invoice:invoices(*)') + .eq('lender_id', user.id) + .order('created_at', { ascending: false }); + if (error) { + // Surface the failure in state (the engine swallows rejections), so the + // page shows an error banner instead of a misleading empty portfolio. + dispatch({ type: 'error', error: `Failed to load financing offers: ${error.message}` }); + return []; + } + const rows = (data as unknown as FinancingOffer[]) ?? []; + // Normalize mirror strings to bigint stroops so math/display are consistent. + return rows.map(offer => ({ + ...offer, + amount: toStroopsBigInt(offer.amount), + amount_repaid: toStroopsBigInt(offer.amount_repaid), + })); + }, []); + + useEffect(() => { + let cancelled = false; + dispatch({ type: 'loading', loading: true }); + + const engine = new LivePortfolioEngine({ + wsUrl: LIVE_WS_URL || null, + contractIds: LIVE_CONTRACT_IDS, + rpcUrl: LIVE_RPC_URL, + networkPassphrase: LIVE_NETWORK_PASSPHRASE, + fetchPositions, + onPositions: offers => { + if (!cancelled) dispatch({ type: 'positions', offers }); + }, + onUpdate: update => { + if (!cancelled) dispatch({ type: 'update', update }); + }, + onConnectionChange: (connection, transport, detail) => { + if (cancelled) return; + dispatch({ type: 'connection', connection, transport, detail }); + }, + }); + engineRef.current = engine; + + void engine.start().finally(() => { + if (!cancelled) dispatch({ type: 'loading', loading: false }); + }); + + return () => { + cancelled = true; + engine.stop(); + engineRef.current = null; + }; + }, [fetchPositions, sessionNonce, publicKey]); + + /** Refresh the XLM/USD price once on mount so USD values are real. */ + useEffect(() => { + let cancelled = false; + void refreshXlmUsdPrice().then(() => { + if (!cancelled) engineRef.current?.resyncNow(); + }); + return () => { + cancelled = true; + }; + }, []); + + const refresh = useCallback(() => { + void refreshXlmUsdPrice().then(() => engineRef.current?.resyncNow()); + }, []); + + const value = useMemo( + () => ({ + positions: state.positions, + connection: state.connection, + connectionDetail: state.connectionDetail, + transport: state.transport, + loading: state.loading, + error: state.error, + lastUpdatedAt: state.lastUpdatedAt, + refresh, + }), + [state, refresh], + ); + + return {children}; +} + +export function useLivePortfolio(): LivePortfolioContextValue { + const ctx = useContext(LivePortfolioContext); + if (!ctx) { + throw new Error('useLivePortfolio must be used within a LivePortfolioProvider'); + } + return ctx; +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/components/portfolio/RepaymentProgress.tsx b/invofi/apps/frontend/src/components/portfolio/RepaymentProgress.tsx new file mode 100644 index 000000000..9c68263a2 --- /dev/null +++ b/invofi/apps/frontend/src/components/portfolio/RepaymentProgress.tsx @@ -0,0 +1,37 @@ +'use client'; + +// ── Streaming repayment progress bar (issue #221) ──────────────────────────── +// Fills as repayments stream in, animating the width transition so partial +// repayments are visually obvious without a full refresh. + +import { cn } from '@/lib/utils'; + +interface RepaymentProgressProps { + /** Ratio 0..1 of total due already repaid. */ + value: number; + className?: string; + label?: string; +} + +export function RepaymentProgress({ value, className, label = 'Repayment progress' }: RepaymentProgressProps) { + const clamped = Math.min(1, Math.max(0, value)); + const pct = Math.round(clamped * 100); + + return ( +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/config.test.ts b/invofi/apps/frontend/src/lib/live/config.test.ts new file mode 100644 index 000000000..5211c1dd8 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/config.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Env is read at module load — reset the module registry so each test sees a +// fresh process.env surface. + +vi.mock('@invofi/sdk', () => ({ + Networks: { + TESTNET: 'Test SDF Network ; September 2015', + PUBLIC: 'Public Global Stellar Network ; September 2015', + }, +})); + +async function loadConfig() { + vi.resetModules(); + return await import('./config'); +} + +afterEach(() => { + delete process.env.NEXT_PUBLIC_STELLAR_NETWORK; + delete process.env.NEXT_PUBLIC_RPC_URL; + delete process.env.NEXT_PUBLIC_WS_URL; +}); + +describe('live config', () => { + it('defaults to the testnet RPC on the testnet network', async () => { + process.env.NEXT_PUBLIC_STELLAR_NETWORK = 'testnet'; + delete process.env.NEXT_PUBLIC_RPC_URL; + const { LIVE_RPC_URL, LIVE_NETWORK_PASSPHRASE } = await loadConfig(); + expect(LIVE_RPC_URL).toBe('https://soroban-testnet.stellar.org'); + expect(LIVE_NETWORK_PASSPHRASE).toContain('Test SDF Network'); + }); + + it('defaults to the mainnet RPC when the network is mainnet', async () => { + process.env.NEXT_PUBLIC_STELLAR_NETWORK = 'mainnet'; + delete process.env.NEXT_PUBLIC_RPC_URL; + const { LIVE_RPC_URL, LIVE_NETWORK_PASSPHRASE } = await loadConfig(); + expect(LIVE_RPC_URL).toBe('https://soroban-rpc.stellar.org'); + expect(LIVE_NETWORK_PASSPHRASE).toContain('Public Global Stellar Network'); + }); + + it('honors an explicit RPC override regardless of network', async () => { + process.env.NEXT_PUBLIC_STELLAR_NETWORK = 'mainnet'; + process.env.NEXT_PUBLIC_RPC_URL = 'https://rpc.example.com'; + const { LIVE_RPC_URL } = await loadConfig(); + expect(LIVE_RPC_URL).toBe('https://rpc.example.com'); + }); +}); \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/config.ts b/invofi/apps/frontend/src/lib/live/config.ts new file mode 100644 index 000000000..e53aaaf07 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/config.ts @@ -0,0 +1,30 @@ +// ── Live dashboard config (issue #221) ─────────────────────────────────────── +// Reads the same env surface as `lib/contract.ts` so the live engine watches +// the same contracts the app talks to. `NEXT_PUBLIC_WS_URL` is optional — when +// unset the dashboard runs on the polling fallback. + +import { Networks } from '@invofi/sdk'; + +export const LIVE_NETWORK = (process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet') as + | 'testnet' + | 'mainnet'; + +/** Soroban RPC endpoint, derived from the network when not explicitly set. */ +export const LIVE_RPC_URL = + process.env.NEXT_PUBLIC_RPC_URL ?? + (LIVE_NETWORK === 'mainnet' + ? 'https://soroban-rpc.stellar.org' + : 'https://soroban-testnet.stellar.org'); + +export const LIVE_WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? ''; + +export const LIVE_NETWORK_PASSPHRASE = + LIVE_NETWORK === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET; + +const LEGACY_CONTRACT_ID = process.env.NEXT_PUBLIC_CONTRACT_ID ?? ''; +const REGISTRY_ID = process.env.NEXT_PUBLIC_REGISTRY_CONTRACT_ID ?? LEGACY_CONTRACT_ID; +const FINANCING_ID = process.env.NEXT_PUBLIC_FINANCING_CONTRACT_ID ?? LEGACY_CONTRACT_ID; +const REPAYMENT_ID = process.env.NEXT_PUBLIC_REPAYMENT_CONTRACT_ID ?? LEGACY_CONTRACT_ID; + +/** Contract IDs to watch, deduped (legacy deployments route all three to one). */ +export const LIVE_CONTRACT_IDS = [...new Set([REGISTRY_ID, FINANCING_ID, REPAYMENT_ID])].filter(Boolean); \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/convert.test.ts b/invofi/apps/frontend/src/lib/live/convert.test.ts new file mode 100644 index 000000000..1f2cb4ef1 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/convert.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { safeStroopsFromWire, stroopsFromWire } from './convert'; + +describe('stroopsFromWire', () => { + it('passes bigints and numbers through as stroops', () => { + expect(stroopsFromWire(1_000_000n)).toBe(1_000_000n); + expect(stroopsFromWire(1_000_000)).toBe(1_000_000n); + }); + + it('rejects unsafe numeric amounts instead of storing a rounded value', () => { + // 9007199254740993 is above Number.MAX_SAFE_INTEGER — a JSON number this + // large is already rounded before this function sees it. + expect(() => stroopsFromWire(9_007_199_254_740_993)).toThrow(RangeError); + expect(() => stroopsFromWire(1.5)).toThrow(RangeError); + expect(() => stroopsFromWire(NaN)).toThrow(RangeError); + }); + + it('treats integer strings as raw stroops (wire convention)', () => { + expect(stroopsFromWire('1000000')).toBe(1_000_000n); + expect(stroopsFromWire('-500')).toBe(-500n); + }); + + it('treats decimal strings as human units, unlike integer strings', () => { + expect(stroopsFromWire('1.0')).toBe(10_000_000n); + expect(stroopsFromWire('1.5')).toBe(15_000_000n); + }); + + it('normalizes empty and nullish values', () => { + expect(stroopsFromWire('')).toBe(0n); + expect(stroopsFromWire(null)).toBe(0n); + expect(stroopsFromWire(undefined)).toBe(0n); + }); + + it('safeStroopsFromWire never throws on malformed values', () => { + expect(safeStroopsFromWire(9_007_199_254_740_993)).toBe(0n); + expect(safeStroopsFromWire({} as never)).toBe(0n); + expect(safeStroopsFromWire('not-a-number')).toBe(0n); + expect(safeStroopsFromWire(1_000_000)).toBe(1_000_000n); + }); +}); \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/convert.ts b/invofi/apps/frontend/src/lib/live/convert.ts new file mode 100644 index 000000000..e2c80846d --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/convert.ts @@ -0,0 +1,38 @@ +// ── Wire-amount conversion (issue #221) ────────────────────────────────────── +// BigInts do not survive JSON, so relay messages carry amounts as integer +// strings that are already in stroops (on-chain i128 values). The general +// `toStroopsBigInt` from `lib/utils` interprets integer strings as *human +// units* (used by the Supabase mirror), which would silently inflate a +// streamed stroop amount by 10⁷. This helper converts wire values explicitly: +// integer strings/numbers → stroops as-is; decimal strings → human units. + +import { toStroopsBigInt } from '@/lib/utils'; + +export function stroopsFromWire(value: bigint | number | string | null | undefined): bigint { + if (value === null || value === undefined) return 0n; + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + // A JSON number above Number.MAX_SAFE_INTEGER is already rounded before + // this function sees it — never store a silently-rounded stroop amount. + // Large wire amounts must arrive as integer strings (on-chain i128). + if (!Number.isSafeInteger(value)) { + throw new RangeError(`Unsafe numeric wire amount: ${value}`); + } + return BigInt(value); + } + const s = String(value).trim(); + if (s === '') return 0n; + // Integer string → already stroops (wire convention). + if (/^-?\d+$/.test(s)) return BigInt(s); + // Decimal string → human units from the mirror; convert. + return toStroopsBigInt(s); +} + +/** Like {@link stroopsFromWire} but never throws — malformed values become 0n. */ +export function safeStroopsFromWire(value: bigint | number | string | null | undefined): bigint { + try { + return stroopsFromWire(value); + } catch { + return 0n; + } +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/engine.test.ts b/invofi/apps/frontend/src/lib/live/engine.test.ts new file mode 100644 index 000000000..93c859503 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/engine.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FinancingOffer } from '@invofi/sdk'; +import { LivePortfolioEngine } from './engine'; +import type { LivePositionUpdate } from './types'; + +const { + wsStartMock, + wsStopMock, + pollingStartMock, + pollingStopMock, + createWsMock, + createPollingMock, +} = vi.hoisted(() => ({ + wsStartMock: vi.fn(), + wsStopMock: vi.fn(), + pollingStartMock: vi.fn(), + pollingStopMock: vi.fn(), + createWsMock: vi.fn(), + createPollingMock: vi.fn(), +})); + +vi.mock('./transports', () => ({ + createWebSocketTransport: (opts: unknown) => { + createWsMock(opts); + return { start: wsStartMock, stop: wsStopMock }; + }, + createPollingTransport: (opts: unknown) => { + createPollingMock(opts); + return { start: pollingStartMock, stop: pollingStopMock }; + }, +})); + +/** Funded "now" so accrual is partial and observable across ticks. */ +const makeActiveOffer = (nowSecs: number): FinancingOffer => ({ + id: 'off_1', + invoice_id: 'inv_1', + lender: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + amount: 10_000_000n, + currency: 'USDC', + interest_rate: 500, + duration: 40, + amount_repaid: 0n, + status: 'Financed', + funded_at: nowSecs, +}); + +describe('LivePortfolioEngine', () => { + beforeEach(() => { + vi.useFakeTimers(); + wsStartMock.mockClear(); + wsStopMock.mockClear(); + pollingStartMock.mockClear(); + pollingStopMock.mockClear(); + createWsMock.mockClear(); + createPollingMock.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('degrades to the polling transport when no WebSocket relay is configured', async () => { + const statuses: Array<[string, string]> = []; + const engine = new LivePortfolioEngine({ + wsUrl: null, + contractIds: ['registry'], + rpcUrl: 'https://rpc', + networkPassphrase: 'testnet', + fetchPositions: async () => [makeActiveOffer(Date.now() / 1000)], + onPositions: () => {}, + onUpdate: () => {}, + onConnectionChange: (connection, transport) => statuses.push([connection, transport]), + }); + + await engine.start(); + + expect(wsStartMock).not.toHaveBeenCalled(); + expect(pollingStartMock).toHaveBeenCalledTimes(1); + expect(statuses).toContainEqual(['polling', 'polling']); + // Contract + network wiring reaches the polling transport. + const pollingOptions = createPollingMock.mock.calls[0][0] as Record; + expect(pollingOptions.contractIds).toEqual(['registry']); + expect(pollingOptions.rpcUrl).toBe('https://rpc'); + expect(pollingOptions.networkPassphrase).toBe('testnet'); + engine.stop(); + }); + + it('prefers the WebSocket relay when configured', async () => { + const engine = new LivePortfolioEngine({ + wsUrl: 'wss://relay.invofi.dev', + contractIds: ['registry'], + rpcUrl: 'https://rpc', + networkPassphrase: 'testnet', + fetchPositions: async () => [], + onPositions: () => {}, + onUpdate: () => {}, + onConnectionChange: () => {}, + }); + + await engine.start(); + + expect(wsStartMock).toHaveBeenCalledTimes(1); + expect(pollingStartMock).not.toHaveBeenCalled(); + // URL and callbacks reach the WebSocket transport. + const wsOptions = createWsMock.mock.calls[0][0] as Record; + expect(wsOptions.url).toBe('wss://relay.invofi.dev'); + expect(typeof wsOptions.onUpdate).toBe('function'); + expect(typeof wsOptions.onGiveUp).toBe('function'); + engine.stop(); + }); + + it('degrades to polling when the configured relay gives up', async () => { + const statuses: Array<[string, string]> = []; + const engine = new LivePortfolioEngine({ + wsUrl: 'wss://relay.invofi.dev', + contractIds: ['registry'], + rpcUrl: 'https://rpc', + networkPassphrase: 'testnet', + fetchPositions: async () => [], + onPositions: () => {}, + onUpdate: () => {}, + onConnectionChange: (connection, transport) => statuses.push([connection, transport]), + }); + + await engine.start(); + const wsOptions = createWsMock.mock.calls[0][0] as { onGiveUp: (reason: string) => void }; + wsOptions.onGiveUp('relay unreachable'); + + expect(wsStopMock).toHaveBeenCalledTimes(1); + expect(pollingStartMock).toHaveBeenCalledTimes(1); + expect(statuses).toContainEqual(['polling', 'polling']); + engine.stop(); + }); + + it('accrues yield in real time, throttled to one update per position per second', async () => { + const updates: Array<{ kind: string; positionId: string; earnedToDate?: bigint }> = []; + const offer = makeActiveOffer(Date.now() / 1000); + const engine = new LivePortfolioEngine({ + wsUrl: null, + contractIds: [], + rpcUrl: 'https://rpc', + networkPassphrase: 'testnet', + fetchPositions: async () => [offer], + onPositions: () => {}, + onUpdate: update => updates.push(update), + onConnectionChange: () => {}, + throttleMs: 1_000, + yieldTickMs: 250, + }); + + await engine.start(); + expect(updates).toHaveLength(0); + + // First accrual lands at 250ms (leading edge); the coalescing window + // closes at +1000ms delivering the latest accrued value. + await vi.advanceTimersByTimeAsync(1_300); + expect(updates).toHaveLength(2); + expect(updates[0].kind).toBe('yield_calculated'); + expect(updates[0].positionId).toBe('off_1'); + // Accrual advances with the clock, not just the tick count. + expect(updates[1].earnedToDate!).toBeGreaterThan(updates[0].earnedToDate!); + + // One more second → exactly one more delivery (never more than 1/sec). + await vi.advanceTimersByTimeAsync(1_300); + expect(updates).toHaveLength(3); + expect(updates[2].earnedToDate!).toBeGreaterThan(updates[1].earnedToDate!); + + engine.stop(); + }); + + it('delivers repayment and yield updates independently for the same position', async () => { + const updates: LivePositionUpdate[] = []; + const engine = new LivePortfolioEngine({ + wsUrl: 'wss://relay.invofi.dev', + contractIds: ['registry'], + rpcUrl: 'https://rpc', + networkPassphrase: 'testnet', + fetchPositions: async () => [makeActiveOffer(Date.now() / 1000)], + onPositions: () => {}, + onUpdate: update => updates.push(update), + onConnectionChange: () => {}, + throttleMs: 1_000, + yieldTickMs: 250, + }); + + await engine.start(); + + const wsOptions = createWsMock.mock.calls[0][0] as { + onUpdate: (update: LivePositionUpdate) => void; + }; + // A repayment arrives while the yield timer is running. + wsOptions.onUpdate({ + kind: 'repayment_received', + positionId: 'off_1', + amountRepaid: 1_000_000n, + fullyRepaid: false, + }); + + await vi.advanceTimersByTimeAsync(1_300); + + const kinds = updates.map(u => u.kind); + expect(kinds).toContain('repayment_received'); + expect(kinds).toContain('yield_calculated'); + engine.stop(); + }); + + it('stop() halts yield ticks and resyncs', async () => { + const updates: unknown[] = []; + let fetches = 0; + const engine = new LivePortfolioEngine({ + wsUrl: null, + contractIds: [], + rpcUrl: 'https://rpc', + networkPassphrase: 'testnet', + fetchPositions: async () => { + fetches++; + return [makeActiveOffer(Date.now() / 1000)]; + }, + onPositions: () => {}, + onUpdate: update => updates.push(update), + onConnectionChange: () => {}, + resyncIntervalMs: 500, + yieldTickMs: 250, + }); + + await engine.start(); + engine.stop(); + const fetchesAtStop = fetches; + + await vi.advanceTimersByTimeAsync(5_000); + + expect(updates).toHaveLength(0); + expect(fetches).toBe(fetchesAtStop); + expect(pollingStopMock).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/engine.ts b/invofi/apps/frontend/src/lib/live/engine.ts new file mode 100644 index 000000000..cd9a7daaa --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/engine.ts @@ -0,0 +1,198 @@ +// ── Live portfolio engine (issue #221) ─────────────────────────────────────── +// Owns the transport lifecycle and the per-position throttling: +// +// 1. Resyncs positions from the caller's fetch function (initial + periodic + +// reconnect safety net). +// 2. Prefers a WebSocket relay; degrades to Soroban event polling when the +// relay is missing or unreachable (graceful-degradation criterion). +// 3. Recomputes accruing yield once per second for active positions, so +// "earned to date" moves in real time even between discrete events. +// 4. Routes every update through a per-position throttle (≤ 1/sec/position) +// before handing it to the reducer. + +import type { FinancingOffer } from '@invofi/sdk'; +import type { PerKeyThrottle } from './throttle'; +import { createPerKeyThrottle } from './throttle'; +import type { ConnectionStatus, LivePositionUpdate, LiveTransport } from './types'; +import { offerApy, yieldEarnedStroops, isActiveOffer } from './yield'; +import { createPollingTransport, createWebSocketTransport, type TransportHandle } from './transports'; + +export interface LivePortfolioEngineOptions { + wsUrl: string | null; + contractIds: string[]; + rpcUrl: string; + networkPassphrase: string; + /** Full-state fetch (e.g. Supabase mirror). Never rejects — return [] on failure. */ + fetchPositions: () => Promise; + onPositions: (offers: FinancingOffer[]) => void; + onUpdate: (update: LivePositionUpdate) => void; + onConnectionChange: (status: ConnectionStatus, transport: LiveTransport, detail?: string) => void; + /** Max one delivered update per position per this window. Default 1000ms. */ + throttleMs?: number; + /** Periodic full resync in both modes. Default 10s. */ + resyncIntervalMs?: number; + /** How often to recompute accruing yield. Default 1000ms. */ + yieldTickMs?: number; +} + +export class LivePortfolioEngine { + private readonly opts: LivePortfolioEngineOptions; + private ws: TransportHandle | null = null; + private polling: TransportHandle | null = null; + private throttle: PerKeyThrottle | null = null; + private resyncTimer: ReturnType | null = null; + private yieldTimer: ReturnType | null = null; + private latestOffers: FinancingOffer[] = []; + /** Last dispatched accrual per position — unchanged ticks are skipped. */ + private readonly lastEarned = new Map(); + private started = false; + private stopped = false; + private degradedToPolling = false; + private resyncInFlight: Promise | null = null; + private resyncGeneration = 0; + + constructor(options: LivePortfolioEngineOptions) { + this.opts = options; + } + + /** Throttle key: one coalescing slot per position per update kind. */ + private static key(update: LivePositionUpdate): string { + return `${update.positionId}:${update.kind}`; + } + + /** Establish the live stream. Resolves after the first positions resync. */ + async start(): Promise { + if (this.started) return; + this.started = true; + + const { + wsUrl, + contractIds, + rpcUrl, + networkPassphrase, + onUpdate, + onConnectionChange, + throttleMs = 1_000, + resyncIntervalMs = 10_000, + yieldTickMs = 1_000, + } = this.opts; + + this.throttle = createPerKeyThrottle(throttleMs, (_key, update) => { + onUpdate(update); + }); + + if (wsUrl) { + this.ws = createWebSocketTransport({ + url: wsUrl, + onUpdate: update => this.throttle?.(LivePortfolioEngine.key(update), update), + onConnectionChange: (status, detail) => onConnectionChange(status, 'websocket', detail), + onResync: () => void this.resync(), + onGiveUp: reason => this.degradeToPolling(reason), + }); + this.ws.start(); + } else { + this.degradeToPolling('no WebSocket relay configured'); + } + + // Continuous accrual: recompute yields for active positions each tick. + // Unchanged values are skipped — a fresh updatedAt alone isn't an update. + this.yieldTimer = setInterval(() => { + if (this.stopped) return; + const nowSecs = Date.now() / 1000; + for (const offer of this.latestOffers) { + if (!isActiveOffer(offer)) continue; + const earnedToDate = yieldEarnedStroops(offer, nowSecs); + if (this.lastEarned.get(offer.id) === earnedToDate) continue; + this.lastEarned.set(offer.id, earnedToDate); + const update: LivePositionUpdate = { + kind: 'yield_calculated', + positionId: offer.id, + apy: offerApy(offer), + earnedToDate, + updatedAt: Date.now(), + }; + this.throttle?.(LivePortfolioEngine.key(update), update); + } + }, yieldTickMs); + + // Periodic safety-net resync (both modes) so brand-new positions appear. + this.resyncTimer = setInterval(() => void this.resync(), resyncIntervalMs); + + await this.resync(); + } + + /** Switch from the relay to the polling fallback, once. */ + private degradeToPolling(reason: string): void { + if (this.stopped || this.degradedToPolling) return; + this.degradedToPolling = true; + this.ws?.stop(); + this.ws = null; + this.opts.onConnectionChange('polling', 'polling', reason); + this.polling = createPollingTransport({ + contractIds: this.opts.contractIds, + rpcUrl: this.opts.rpcUrl, + networkPassphrase: this.opts.networkPassphrase, + onUpdate: update => this.throttle?.(LivePortfolioEngine.key(update), update), + onConnectionChange: (status, detail) => + this.opts.onConnectionChange(status, 'polling', detail), + onResync: () => void this.resync(), + }); + this.polling.start(); + } + + /** + * Full-state resync. In-flight requests are shared (never concurrent), and a + * generation counter discards responses superseded by a newer resync so a + * stale response can't overwrite fresher positions. + */ + private resync(): Promise { + if (this.stopped) return Promise.resolve(); + if (this.resyncInFlight) return this.resyncInFlight; + const generation = ++this.resyncGeneration; + this.resyncInFlight = this.opts + .fetchPositions() + .then(offers => { + if (this.stopped || generation !== this.resyncGeneration) return; + this.latestOffers = offers; + this.opts.onPositions(offers); + }) + .catch(() => { + // A failed resync is non-fatal — the live stream keeps the last state. + }) + .then(() => { + if (generation === this.resyncGeneration) this.resyncInFlight = null; + }); + return this.resyncInFlight; + } + + /** Force an immediate full resync (used by refresh buttons + auth changes). */ + resyncNow(): void { + void this.resync(); + } + + /** + * Tear down every timer and transport. Terminal: an engine cannot be + * restarted after `stop()`. Construct a new instance instead. + */ + stop(): void { + this.stopped = true; + this.resyncGeneration += 1; + if (this.resyncTimer) { + clearInterval(this.resyncTimer); + this.resyncTimer = null; + } + if (this.yieldTimer) { + clearInterval(this.yieldTimer); + this.yieldTimer = null; + } + this.ws?.stop(); + this.ws = null; + this.polling?.stop(); + this.polling = null; + // Deliver anything still pending before dropping the throttle. + this.throttle?.flush(); + this.throttle?.stop(); + this.throttle = null; + this.lastEarned.clear(); + } +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/prices.test.ts b/invofi/apps/frontend/src/lib/live/prices.test.ts new file mode 100644 index 000000000..3f54d85da --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/prices.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { __setXlmUsdPriceForTests, refreshXlmUsdPrice, stroopsToUsd, usdPriceFor } from './prices'; + +describe('prices', () => { + afterEach(() => { + __setXlmUsdPriceForTests(null); + vi.unstubAllGlobals(); + }); + + it('treats USDC as a $1 stablecoin', () => { + expect(usdPriceFor('USDC')).toBe(1); + }); + + it('uses the cached XLM price once set, defaulting to $1 otherwise', () => { + __setXlmUsdPriceForTests(0.5); + expect(usdPriceFor('XLM')).toBe(0.5); + expect(stroopsToUsd(10_000_000n, 'XLM')).toBeCloseTo(0.5, 5); + + __setXlmUsdPriceForTests(null); + expect(usdPriceFor('XLM')).toBe(1); + }); + + it('falls back without rejecting when the live price feed is unreachable', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))); + await expect(refreshXlmUsdPrice()).resolves.toBeGreaterThan(0); + expect(usdPriceFor('XLM')).toBeGreaterThan(0); + }); + + it('uses a fetched price when the feed responds', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ stellar: { usd: 0.42 } }), { status: 200 })), + ); + await expect(refreshXlmUsdPrice()).resolves.toBeCloseTo(0.42, 5); + expect(usdPriceFor('XLM')).toBeCloseTo(0.42, 5); + }); +}); \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/prices.ts b/invofi/apps/frontend/src/lib/live/prices.ts new file mode 100644 index 000000000..1cd6c2a80 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/prices.ts @@ -0,0 +1,72 @@ +// ── USD pricing (issue #221) ───────────────────────────────────────────────── +// The live dashboard shows every position value in USD. USDC is a stablecoin +// (peg ≈ $1). XLM is priced from a light, cached source: an env override wins, +// otherwise CoinGecko's public endpoint is tried, and both fall back to the +// override's default so the dashboard still renders offline. + +import type { Currency } from '@invofi/sdk'; +import { STROOPS_PER_XLM } from '@/lib/constants'; + +const XLM_USD_CACHE_TTL_MS = 5 * 60_000; + +const ENV_XLM_USD_PRICE = Number(process.env.NEXT_PUBLIC_XLM_USD_PRICE ?? ''); +const DEFAULT_XLM_USD_PRICE = 1; + +let cachedXlmUsd: number | null = null; +let cachedXlmUsdAt = 0; + +/** Best known XLM price, or the fallback when nothing is cached yet. */ +function xlmUsdPrice(): number { + if (cachedXlmUsd !== null) return cachedXlmUsd; + if (Number.isFinite(ENV_XLM_USD_PRICE) && ENV_XLM_USD_PRICE > 0) return ENV_XLM_USD_PRICE; + return DEFAULT_XLM_USD_PRICE; +} + +/** + * USD price for a currency. USDC is treated as $1; XLM uses the cached price + * (see {@link refreshXlmUsdPrice}). + */ +export function usdPriceFor(currency: Currency): number { + return currency === 'USDC' ? 1 : xlmUsdPrice(); +} + +/** Convert stroops to USD at the current price. */ +export function stroopsToUsd(stroops: bigint, currency: Currency): number { + return (Number(stroops) / STROOPS_PER_XLM) * usdPriceFor(currency); +} + +/** + * Refresh the cached XLM/USD price. Tries the CoinGecko public endpoint and + * falls back to the `NEXT_PUBLIC_XLM_USD_PRICE` env override, then the + * built-in default. Never rejects — a failure just keeps the last price. + */ +export async function refreshXlmUsdPrice(): Promise { + if (cachedXlmUsd !== null && Date.now() - cachedXlmUsdAt < XLM_USD_CACHE_TTL_MS) { + return cachedXlmUsd; + } + try { + const res = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd', + ); + if (res.ok) { + const json = (await res.json()) as { stellar?: { usd?: number } }; + const price = Number(json?.stellar?.usd); + if (Number.isFinite(price) && price > 0) { + cachedXlmUsd = price; + cachedXlmUsdAt = Date.now(); + return price; + } + } + } catch { + // Network unavailable — fall through to the override / default. + } + cachedXlmUsd = xlmUsdPrice(); + cachedXlmUsdAt = Date.now(); + return cachedXlmUsd; +} + +/** Test hook — pin the cached XLM price without a network call. */ +export function __setXlmUsdPriceForTests(price: number | null): void { + cachedXlmUsd = price; + cachedXlmUsdAt = price === null ? 0 : Date.now(); +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/reducer.test.ts b/invofi/apps/frontend/src/lib/live/reducer.test.ts new file mode 100644 index 000000000..f6a28f1cd --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/reducer.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; +import type { FinancingOffer } from '@invofi/sdk'; +import { + INITIAL_LIVE_PORTFOLIO_STATE, + deriveLivePosition, + livePortfolioReducer, +} from './reducer'; +import { __setXlmUsdPriceForTests } from './prices'; + +const DAY = 86_400; +const offer: FinancingOffer = { + id: 'off_1', + invoice_id: 'inv_1', + lender: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + amount: 10_000_000n, // 1 XLM + currency: 'USDC', + interest_rate: 500, + duration: 30 * DAY, + amount_repaid: 0n, + status: 'Financed', + funded_at: 1_000_000, +}; + +describe('deriveLivePosition', () => { + it('computes apy, earned-to-date, remaining, progress, and USD value', () => { + __setXlmUsdPriceForTests(null); // XLM fallback = 1 + const live = deriveLivePosition(offer, 1_000_000 + 15 * DAY); + + expect(live.apy).toBeCloseTo(60.83, 1); + expect(live.earnedToDate).toBe(250_000n); // half of the 5% yield + expect(live.totalDue).toBe(10_500_000n); + expect(live.remaining).toBe(10_500_000n); + expect(live.repaymentProgress).toBe(0); + expect(live.liveValueUsd).toBeCloseTo(1.05, 5); + }); +}); + +describe('livePortfolioReducer', () => { + it('starts loading with an empty portfolio', () => { + expect(livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { type: 'reset' })).toEqual( + INITIAL_LIVE_PORTFOLIO_STATE, + ); + }); + + it('derives live rows from a full positions resync', () => { + const state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + expect(state.loading).toBe(false); + expect(state.positions).toHaveLength(1); + expect(state.positions[0].remaining).toBe(10_500_000n); + expect(state.lastUpdatedAt).not.toBeNull(); + }); + + it('applies a streamed repayment to the matching position', () => { + let state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + state = livePortfolioReducer(state, { + type: 'update', + update: { + kind: 'repayment_received', + positionId: 'off_1', + amountRepaid: 2_000_000n, + fullyRepaid: false, + updatedAt: 1_000, + }, + }); + + expect(state.positions[0].amount_repaid).toBe(2_000_000n); + expect(state.positions[0].remaining).toBe(8_500_000n); + expect(state.positions[0].repaymentProgress).toBeCloseTo(2_000_000 / 10_500_000, 5); + }); + + it('marks a position Repaid when the final repayment arrives', () => { + let state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + state = livePortfolioReducer(state, { + type: 'update', + update: { + kind: 'repayment_received', + positionId: 'off_1', + amountRepaid: 10_500_000n, + fullyRepaid: true, + }, + }); + + expect(state.positions[0].status).toBe('Repaid'); + expect(state.positions[0].repaymentProgress).toBe(1); + }); + + it('uses a cumulative remaining to keep replayed repayments idempotent', () => { + let state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + const replay = { + kind: 'repayment_received' as const, + positionId: 'off_1', + amountRepaid: 2_000_000n, + remaining: 8_500_000n, + fullyRepaid: false, + }; + + state = livePortfolioReducer(state, { type: 'update', update: replay }); + expect(state.positions[0].amount_repaid).toBe(2_000_000n); + expect(state.positions[0].remaining).toBe(8_500_000n); + + // The same delivery replays after a reconnect — must not double-count. + state = livePortfolioReducer(state, { type: 'update', update: replay }); + expect(state.positions[0].amount_repaid).toBe(2_000_000n); + expect(state.positions[0].remaining).toBe(8_500_000n); + }); + + it('ignores stale cumulative repayments that arrived out of order', () => { + let state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + // A fresh delivery first... + state = livePortfolioReducer(state, { + type: 'update', + update: { + kind: 'repayment_received', + positionId: 'off_1', + amountRepaid: 2_000_000n, + remaining: 8_500_000n, + fullyRepaid: false, + }, + }); + + // ...then an older delivery replays with a higher (staler) remaining. + state = livePortfolioReducer(state, { + type: 'update', + update: { + kind: 'repayment_received', + positionId: 'off_1', + amountRepaid: 2_000_000n, + remaining: 9_500_000n, + fullyRepaid: false, + }, + }); + + expect(state.positions[0].amount_repaid).toBe(2_000_000n); + expect(state.positions[0].remaining).toBe(8_500_000n); + }); + + it('falls back to incremental amounts when no cumulative value is sent', () => { + let state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + state = livePortfolioReducer(state, { + type: 'update', + update: { + kind: 'repayment_received', + positionId: 'off_1', + amountRepaid: 2_000_000n, + fullyRepaid: false, + }, + }); + + expect(state.positions[0].amount_repaid).toBe(2_000_000n); + }); + + it('applies a yield_calculated stream update', () => { + let state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + state = livePortfolioReducer(state, { + type: 'update', + update: { + kind: 'yield_calculated', + positionId: 'off_1', + apy: 61, + earnedToDate: 300_000n, + }, + }); + + expect(state.positions[0].apy).toBe(61); + expect(state.positions[0].earnedToDate).toBe(300_000n); + }); + + it('merges partial offer fields from a position_updated event', () => { + let state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + state = livePortfolioReducer(state, { + type: 'update', + update: { + kind: 'position_updated', + positionId: 'off_1', + fields: { status: 'Repaid', amount_repaid: '10500000' }, + }, + }); + + expect(state.positions[0].status).toBe('Repaid'); + expect(state.positions[0].amount_repaid).toBe(10_500_000n); + }); + + it('never crashes the reducer on malformed wire amounts', () => { + let state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + state = livePortfolioReducer(state, { + type: 'update', + update: { + kind: 'position_updated', + positionId: 'off_1', + fields: { amount_repaid: 'not-a-number' }, + }, + }); + + expect(state.positions[0].amount_repaid).toBe(0n); + }); + + it('ignores updates for positions it does not know about', () => { + const state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'positions', + offers: [offer], + }); + + const next = livePortfolioReducer(state, { + type: 'update', + update: { kind: 'repayment_received', positionId: 'off_unknown', amountRepaid: 1n, fullyRepaid: false }, + }); + + expect(next).toBe(state); + }); + + it('records the connection status and transport', () => { + const state = livePortfolioReducer(INITIAL_LIVE_PORTFOLIO_STATE, { + type: 'connection', + connection: 'polling', + transport: 'polling', + detail: 'relay unreachable', + }); + + expect(state.connection).toBe('polling'); + expect(state.transport).toBe('polling'); + expect(state.connectionDetail).toBe('relay unreachable'); + }); +}); \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/reducer.ts b/invofi/apps/frontend/src/lib/live/reducer.ts new file mode 100644 index 000000000..b13496a93 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/reducer.ts @@ -0,0 +1,169 @@ +// ── Live portfolio reducer (issue #221) ────────────────────────────────────── +// Pure state transitions for the dashboard. The context provider dispatches +// here from the engine; components read the resulting `LivePosition`s. Because +// every action goes through this reducer, tests can drive the full data flow +// without React. + +import type { FinancingOffer } from '@invofi/sdk'; +import { toStroopsBigInt } from '@/lib/utils'; +import { safeStroopsFromWire } from './convert'; +import type { ConnectionStatus, LivePosition, LivePositionUpdate, LiveTransport } from './types'; +import { offerApy, remainingStroops, repaymentProgress, totalDueStroops, yieldEarnedStroops } from './yield'; +import { usdPriceFor } from './prices'; +import { STROOPS_PER_XLM } from '@/lib/constants'; + +export interface LivePortfolioState { + connection: ConnectionStatus; + /** Optional human-readable detail for the status pill (e.g. "retry in 2s"). */ + connectionDetail: string | null; + transport: LiveTransport; + positions: LivePosition[]; + loading: boolean; + error: string | null; + /** Epoch ms of the last data-changing event (for "updated Xs ago" hints). */ + lastUpdatedAt: number | null; +} + +export type LivePortfolioAction = + | { type: 'connection'; connection: ConnectionStatus; transport: LiveTransport; detail?: string | null } + | { type: 'positions'; offers: FinancingOffer[] } + | { type: 'update'; update: LivePositionUpdate } + | { type: 'loading'; loading: boolean } + | { type: 'error'; error: string | null } + | { type: 'reset' }; + +export const INITIAL_LIVE_PORTFOLIO_STATE: LivePortfolioState = { + connection: 'connecting', + connectionDetail: null, + transport: 'websocket', + positions: [], + loading: true, + error: null, + lastUpdatedAt: null, +}; + +/** Compute every derived live field for a single position row. */ +export function deriveLivePosition( + offer: FinancingOffer, + nowSecs: number = Date.now() / 1000, +): LivePosition { + const price = usdPriceFor(offer.currency); + const remaining = remainingStroops(offer); + return { + ...offer, + amount: toStroopsBigInt(offer.amount), + amount_repaid: toStroopsBigInt(offer.amount_repaid), + totalDue: totalDueStroops(offer), + remaining, + repaymentProgress: repaymentProgress(offer), + apy: offerApy(offer), + earnedToDate: yieldEarnedStroops(offer, nowSecs), + liveValueUsd: (Number(remaining) / STROOPS_PER_XLM) * price, + updatedAt: Date.now(), + }; +} + +/** Build a live row from a stored row + an update, or undefined if unknown. */ +function applyUpdate( + position: LivePosition | undefined, + update: LivePositionUpdate, +): LivePosition | undefined { + if (!position) return undefined; + const now = update.updatedAt ?? Date.now(); + + switch (update.kind) { + case 'position_updated': { + const merged: FinancingOffer = { + ...position, + status: update.fields.status ?? position.status, + funded_at: + update.fields.funded_at !== undefined ? Number(update.fields.funded_at) : position.funded_at, + currency: update.fields.currency ?? position.currency, + amount: safeStroopsFromWire(update.fields.amount ?? position.amount), + amount_repaid: safeStroopsFromWire(update.fields.amount_repaid ?? position.amount_repaid), + }; + const derived = deriveLivePosition(merged, now / 1000); + return { ...derived, updatedAt: now }; + } + + case 'yield_calculated': + return { + ...position, + apy: update.apy, + earnedToDate: update.earnedToDate, + updatedAt: now, + }; + + case 'repayment_received': { + let amountRepaid: bigint; + if (update.remaining !== undefined) { + // Cumulative outstanding claim: monotonic, so a replayed or stale + // delivery can never double-count a repayment. + const derived = totalDueStroops(position) - update.remaining; + amountRepaid = derived > position.amount_repaid ? derived : position.amount_repaid; + } else { + amountRepaid = position.amount_repaid + update.amountRepaid; + } + const merged = { + ...position, + amount_repaid: amountRepaid, + }; + const derived = deriveLivePosition(merged, now / 1000); + return { + ...derived, + status: update.fullyRepaid ? 'Repaid' : derived.status, + updatedAt: now, + }; + } + } +} + +export function livePortfolioReducer( + state: LivePortfolioState, + action: LivePortfolioAction, +): LivePortfolioState { + switch (action.type) { + case 'connection': + return { + ...state, + connection: action.connection, + transport: action.transport, + connectionDetail: action.detail ?? null, + }; + + case 'positions': + return { + ...state, + positions: action.offers.map(offer => deriveLivePosition(offer)), + loading: false, + error: null, + lastUpdatedAt: Date.now(), + }; + + case 'update': { + const { update } = action; + let changed = false; + const positions = state.positions.map(position => { + if (position.id !== update.positionId) return position; + const next = applyUpdate(position, update); + if (!next) return position; + changed = true; + return next; + }); + if (!changed) return state; + return { ...state, positions, lastUpdatedAt: Date.now() }; + } + + case 'loading': + return { ...state, loading: action.loading }; + + case 'error': + return { ...state, error: action.error }; + + case 'reset': + return INITIAL_LIVE_PORTFOLIO_STATE; + + default: + return state; + } +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/throttle.test.ts b/invofi/apps/frontend/src/lib/live/throttle.test.ts new file mode 100644 index 000000000..ae201b21c --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/throttle.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createPerKeyThrottle } from './throttle'; + +describe('createPerKeyThrottle', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('delivers the leading edge immediately, then coalesces the window', () => { + vi.useFakeTimers(); + const delivered: Array<[string, number]> = []; + const throttle = createPerKeyThrottle(1_000, (key, value) => delivered.push([key, value])); + + throttle('a', 1); // leading edge — delivered now + throttle('a', 2); + throttle('a', 3); + throttle('b', 10); // leading edge — delivered now + + expect(delivered).toEqual([ + ['a', 1], + ['b', 10], + ]); + + vi.advanceTimersByTime(1_000); + + expect(delivered).toEqual([ + ['a', 1], + ['b', 10], + ['a', 3], + ]); + }); + + it('delivers at most once per interval per key', () => { + vi.useFakeTimers(); + const delivered: Array<[string, number]> = []; + const throttle = createPerKeyThrottle(1_000, (key, value) => delivered.push([key, value])); + + throttle('a', 0); // leading edge at t=0 + for (let i = 1; i < 6; i++) { + throttle('a', i); + vi.advanceTimersByTime(100); + } + // Window opened at t=0 closes at t=1000, delivering the coalesced latest. + vi.advanceTimersByTime(1_000); + expect(delivered).toEqual([ + ['a', 0], + ['a', 5], + ]); + }); + + it('keeps per-key windows independent', () => { + vi.useFakeTimers(); + const delivered: string[] = []; + const throttle = createPerKeyThrottle(1_000, key => delivered.push(key)); + + throttle('a', '1'); + vi.advanceTimersByTime(500); + throttle('b', '2'); + vi.advanceTimersByTime(500); // a window closes (nothing pending); b not yet due + throttle('a', '3'); // a window is open again → leading edge + vi.advanceTimersByTime(1_000); // b window closes (nothing pending) + + expect(delivered).toEqual(['a', 'b', 'a']); + }); + + it('flush delivers pending values immediately and cancels armed timers', () => { + vi.useFakeTimers(); + const delivered: number[] = []; + const throttle = createPerKeyThrottle(1_000, (_key, value) => delivered.push(value)); + + throttle('a', 1); // leading edge + throttle('a', 2); // pending + throttle.flush(); + vi.advanceTimersByTime(2_000); + + expect(delivered).toEqual([1, 2]); + }); + + it('stop cancels timers and drops pending values', () => { + vi.useFakeTimers(); + const delivered: number[] = []; + const throttle = createPerKeyThrottle(1_000, (_key, value) => delivered.push(value)); + + throttle('a', 1); // leading edge already delivered + throttle('a', 2); // pending — dropped by stop() + throttle.stop(); + vi.advanceTimersByTime(2_000); + + expect(delivered).toEqual([1]); + }); +}); \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/throttle.ts b/invofi/apps/frontend/src/lib/live/throttle.ts new file mode 100644 index 000000000..4159e8cc1 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/throttle.ts @@ -0,0 +1,70 @@ +// ── Per-position update throttle (issue #221) ──────────────────────────────── +// Live sources can burst many updates for the same position in a single second +// (a repayment transaction fires multiple on-chain events, a relay may fan out +// several messages). The acceptance criteria cap UI churn at one update per +// position per second. The first update for a key is delivered immediately +// (leading edge); further updates within the window coalesce, and the *latest* +// is delivered when the window closes. + +export interface PerKeyThrottle { + /** Queue `value` for `key`, coalescing with anything already pending. */ + (key: string, value: T): void; + /** Immediately deliver everything still pending. Used on shutdown. */ + flush: () => void; + /** Cancel pending timers and drop queued values. */ + stop: () => void; +} + +/** + * Returns a throttled dispatcher that calls `deliver` at most once per `key` + * per `intervalMs`, always with the most recent value. + */ +export function createPerKeyThrottle( + intervalMs: number, + deliver: (key: string, value: T) => void, +): PerKeyThrottle { + const pending = new Map(); + const timers = new Map>(); + let stopped = false; + + function dispatch(key: string, value: T): void { + if (stopped) return; + if (!timers.has(key)) { + // Leading edge: deliver now, then open the coalescing window. The cap is + // one delivery per interval per key — not a minimum one-second latency. + deliver(key, value); + timers.set( + key, + setTimeout(() => { + timers.delete(key); + if (!pending.has(key)) return; + const latest = pending.get(key) as T; + pending.delete(key); + dispatch(key, latest); + }, intervalMs), + ); + return; + } + pending.set(key, value); + } + + dispatch.flush = () => { + if (stopped) return; + // Cancel armed timers so nothing fires after the flush. + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + for (const [key, value] of pending) { + pending.delete(key); + deliver(key, value); + } + }; + + dispatch.stop = () => { + stopped = true; + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + pending.clear(); + }; + + return dispatch; +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/transports.test.ts b/invofi/apps/frontend/src/lib/live/transports.test.ts new file mode 100644 index 000000000..3742d52ec --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/transports.test.ts @@ -0,0 +1,436 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPollingTransport, createWebSocketTransport, decodeEnvelope } from './transports'; + +// ── SDK mocking ────────────────────────────────────────────────────────────── +// The polling transport delegates to the SDK's listenToEvents; stub it so we +// can assert wiring without a real RPC. + +const { listenToEventsMock } = vi.hoisted(() => ({ + listenToEventsMock: vi.fn(), +})); + +vi.mock('@invofi/sdk', () => ({ + listenToEvents: listenToEventsMock, +})); + +// ── Fake WebSocket for the relay transport tests ──────────────────────────── + +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + url: string; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + closed = false; + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + close() { + this.closed = true; + } + + emitOpen() { + this.onopen?.(); + } + + emitClose() { + this.onclose?.(); + } + + emitMessage(data: string) { + this.onmessage?.({ data }); + } +} + +describe('decodeEnvelope', () => { + it('decodes position_updated with a received-time stamp', () => { + expect( + decodeEnvelope( + JSON.stringify({ type: 'position_updated', positionId: 'off_1', fields: { status: 'Financed' } }), + ), + ).toEqual({ + kind: 'position_updated', + positionId: 'off_1', + fields: { status: 'Financed' }, + updatedAt: expect.any(Number), + }); + }); + + it('decodes yield_calculated with string bigint amounts', () => { + expect( + decodeEnvelope( + JSON.stringify({ type: 'yield_calculated', positionId: 'off_1', apy: 12.5, earnedToDate: '250000' }), + ), + ).toEqual({ + kind: 'yield_calculated', + positionId: 'off_1', + apy: 12.5, + earnedToDate: 250_000n, + updatedAt: expect.any(Number), + }); + }); + + it('decodes repayment_received and forwards the cumulative remaining', () => { + expect( + decodeEnvelope( + JSON.stringify({ + type: 'repayment_received', + positionId: 'off_1', + amountRepaid: '1000000', + remaining: '5000000', + progress: 0.2, + fullyRepaid: false, + }), + ), + ).toEqual({ + kind: 'repayment_received', + positionId: 'off_1', + amountRepaid: 1_000_000n, + remaining: 5_000_000n, + fullyRepaid: false, + updatedAt: expect.any(Number), + }); + }); + + it('accepts repayment envelopes that omit progress', () => { + const decoded = decodeEnvelope( + JSON.stringify({ + type: 'repayment_received', + positionId: 'off_1', + amountRepaid: '1000000', + fullyRepaid: false, + }), + ); + expect(decoded).not.toBeNull(); + expect(decoded && decoded.kind).toBe('repayment_received'); + }); + + it('rejects repayment envelopes with malformed or unsafe amounts', () => { + expect( + decodeEnvelope( + JSON.stringify({ type: 'repayment_received', positionId: 'off_1', amountRepaid: {}, fullyRepaid: false }), + ), + ).toBeNull(); + // Above Number.MAX_SAFE_INTEGER → stroopsFromWire throws → caught → null. + expect( + decodeEnvelope( + JSON.stringify({ type: 'repayment_received', positionId: 'off_1', amountRepaid: 9007199254740993, fullyRepaid: false }), + ), + ).toBeNull(); + }); + + it('returns null for malformed or unknown envelopes', () => { + expect(decodeEnvelope('not json')).toBeNull(); + expect(decodeEnvelope('{}')).toBeNull(); + expect(decodeEnvelope(JSON.stringify({ type: 'nope', positionId: 'off_1' }))).toBeNull(); + }); +}); + +describe('createWebSocketTransport', () => { + beforeEach(() => { + FakeWebSocket.instances = []; + vi.stubGlobal('WebSocket', FakeWebSocket); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('connects, reports connected, and forwards relay updates', () => { + const updates: unknown[] = []; + const statuses: string[] = []; + const transport = createWebSocketTransport({ + url: 'wss://relay.invofi.dev', + onUpdate: update => updates.push(update), + onConnectionChange: status => statuses.push(status), + onResync: () => {}, + onGiveUp: () => {}, + }); + + transport.start(); + FakeWebSocket.instances[0].emitOpen(); + FakeWebSocket.instances[0].emitMessage( + JSON.stringify({ type: 'repayment_received', positionId: 'off_1', amountRepaid: 5, progress: 0, fullyRepaid: false }), + ); + + expect(statuses).toEqual(['connecting', 'connected']); + expect(updates).toHaveLength(1); + }); + + it('reconnects with exponential backoff after a drop (≤ 5s first retry)', () => { + const statuses: string[] = []; + const transport = createWebSocketTransport({ + url: 'wss://relay.invofi.dev', + onUpdate: () => {}, + onConnectionChange: status => statuses.push(status), + onResync: () => {}, + onGiveUp: () => {}, + reconnectBaseMs: 1_000, + maxReconnectAttempts: 3, + }); + + transport.start(); + FakeWebSocket.instances[0].emitOpen(); // connected once + FakeWebSocket.instances[0].emitClose(); // drop + + expect(statuses).toEqual(['connecting', 'connected', 'reconnecting']); + + vi.advanceTimersByTime(999); + expect(FakeWebSocket.instances).toHaveLength(1); // not yet reconnected + vi.advanceTimersByTime(1); + expect(FakeWebSocket.instances).toHaveLength(2); // first retry within 1s + + FakeWebSocket.instances[1].emitOpen(); + FakeWebSocket.instances[1].emitClose(); + vi.advanceTimersByTime(2_000); // backoff doubled → 2s + expect(FakeWebSocket.instances).toHaveLength(3); + }); + + it('gives up to the polling fallback after repeated failed initial attempts', () => { + const giveUp: string[] = []; + const transport = createWebSocketTransport({ + url: 'wss://relay.invofi.dev', + onUpdate: () => {}, + onConnectionChange: () => {}, + onResync: () => {}, + onGiveUp: reason => giveUp.push(reason), + reconnectBaseMs: 1_000, + maxReconnectAttempts: 3, + }); + + transport.start(); // attempt 1 (instance 0) + FakeWebSocket.instances[0].emitClose(); + vi.advanceTimersByTime(2_000); // → attempt 2 (instance 1) + FakeWebSocket.instances[1].emitClose(); + vi.advanceTimersByTime(4_000); // → attempt 3 (instance 2) + FakeWebSocket.instances[2].emitClose(); + + expect(giveUp).toHaveLength(1); + expect(FakeWebSocket.instances).toHaveLength(3); // no further attempts + }); + + it('hands off to polling after repeated failures once connected', () => { + const giveUp: string[] = []; + const statuses: string[] = []; + const transport = createWebSocketTransport({ + url: 'wss://relay.invofi.dev', + onUpdate: () => {}, + onConnectionChange: status => statuses.push(status), + onResync: () => {}, + onGiveUp: reason => giveUp.push(reason), + reconnectBaseMs: 1_000, + maxReconnectAttempts: 3, + maxRelayFailures: 2, + }); + + transport.start(); + FakeWebSocket.instances[0].emitOpen(); // connected + FakeWebSocket.instances[0].emitClose(); // failure 1 → reconnect at 1s + vi.advanceTimersByTime(1_000); + expect(FakeWebSocket.instances).toHaveLength(2); + + FakeWebSocket.instances[1].emitOpen(); + FakeWebSocket.instances[1].emitClose(); // failure 2 ≥ maxRelayFailures → give up + expect(giveUp).toEqual(['connection closed']); + vi.advanceTimersByTime(10_000); + expect(FakeWebSocket.instances).toHaveLength(2); // no more reconnects + }); + + it('treats a stalled handshake as a failed connect', () => { + const statuses: string[] = []; + const transport = createWebSocketTransport({ + url: 'wss://relay.invofi.dev', + onUpdate: () => {}, + onConnectionChange: status => statuses.push(status), + onResync: () => {}, + onGiveUp: () => {}, + connectTimeoutMs: 1_000, + }); + + transport.start(); + expect(FakeWebSocket.instances).toHaveLength(1); + + vi.advanceTimersByTime(1_000); // no onopen ever fires + expect(FakeWebSocket.instances[0].closed).toBe(true); + expect(statuses).toContain('connecting'); + // A retry is scheduled after the timed-out attempt. + vi.advanceTimersByTime(2_000); + expect(FakeWebSocket.instances).toHaveLength(2); + }); + + it('stop() cancels pending reconnects and detaches handlers', () => { + const updates: unknown[] = []; + const transport = createWebSocketTransport({ + url: 'wss://relay.invofi.dev', + onUpdate: update => updates.push(update), + onConnectionChange: () => {}, + onResync: () => {}, + onGiveUp: () => {}, + reconnectBaseMs: 1_000, + maxReconnectAttempts: 3, + }); + + transport.start(); + const first = FakeWebSocket.instances[0]; + first.emitOpen(); + first.emitClose(); // schedules a reconnect + transport.stop(); + + vi.advanceTimersByTime(10_000); + expect(FakeWebSocket.instances).toHaveLength(1); + + first.emitMessage( + JSON.stringify({ type: 'position_updated', positionId: 'off_1', fields: {} }), + ); + expect(updates).toHaveLength(0); + }); +}); + +describe('createPollingTransport', () => { + beforeEach(() => { + listenToEventsMock.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('subscribes to protocol events and reports polling status', () => { + const statuses: string[] = []; + const transport = createPollingTransport({ + contractIds: ['registry', 'financing', 'repayment'], + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'testnet', + onUpdate: () => {}, + onConnectionChange: status => statuses.push(status), + onResync: () => {}, + }); + + transport.start(); + + expect(statuses).toEqual(['polling']); + expect(listenToEventsMock).toHaveBeenCalledWith( + expect.objectContaining({ + eventTypes: expect.arrayContaining(['inv_rep', 'off_acc']), + contractIds: ['registry', 'financing', 'repayment'], + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'testnet', + pollIntervalMs: 5_000, + }), + ); + }); + + it('maps inv_rep events to repayment updates without an extra resync', () => { + type TestEvent = { + type: string; + subjectId: string; + data: { amount: bigint; fullyRepaid: boolean }; + }; + const updates: unknown[] = []; + let resyncs = 0; + const captured: { onEvent: ((event: TestEvent) => void) | null } = { onEvent: null }; + listenToEventsMock.mockImplementation((opts: { onEvent: (event: TestEvent) => void }) => { + captured.onEvent = opts.onEvent; + return () => {}; + }); + + const transport = createPollingTransport({ + contractIds: ['repayment'], + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'testnet', + onUpdate: update => updates.push(update), + onConnectionChange: () => {}, + onResync: () => resyncs++, + }); + + transport.start(); + captured.onEvent?.({ + type: 'inv_rep', + subjectId: 'off_1', + data: { amount: 1_000_000n, fullyRepaid: false }, + }); + + expect(updates).toEqual([ + { + kind: 'repayment_received', + positionId: 'off_1', + amountRepaid: 1_000_000n, + fullyRepaid: false, + updatedAt: expect.any(Number), + }, + ]); + expect(resyncs).toBe(0); + }); + + it('coalesces a batch of non-repayment events into a single resync', () => { + vi.useFakeTimers(); + type TestEvent = { type: string; subjectId: string }; + const captured: { onEvent: ((event: TestEvent) => void) | null } = { onEvent: null }; + listenToEventsMock.mockImplementation((opts: { onEvent: (event: TestEvent) => void }) => { + captured.onEvent = opts.onEvent; + return () => {}; + }); + let resyncs = 0; + + const transport = createPollingTransport({ + contractIds: ['registry'], + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'testnet', + onUpdate: () => {}, + onConnectionChange: () => {}, + onResync: () => resyncs++, + }); + + transport.start(); + captured.onEvent?.({ type: 'off_acc', subjectId: 'off_1' }); + captured.onEvent?.({ type: 'off_acc', subjectId: 'off_2' }); + expect(resyncs).toBe(0); + + vi.advanceTimersByTime(0); // debounce fires once for the whole batch + expect(resyncs).toBe(1); + }); + + it('forwards poll errors to the connection detail', () => { + const statuses: string[] = []; + const captured: { onError: ((error: Error) => void) | null } = { onError: null }; + listenToEventsMock.mockImplementation((opts: { onError: (error: Error) => void }) => { + captured.onError = opts.onError; + return () => {}; + }); + + const transport = createPollingTransport({ + contractIds: ['repayment'], + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'testnet', + onUpdate: () => {}, + onConnectionChange: (status, detail) => statuses.push(status, detail ?? ''), + onResync: () => {}, + }); + + transport.start(); + captured.onError?.(new Error('rpc unreachable')); + + expect(statuses).toContain('polling'); + expect(statuses).toContain('poll error: rpc unreachable'); + }); + + it('does not call listenToEvents when no contracts are configured', () => { + const transport = createPollingTransport({ + contractIds: [], + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'testnet', + onUpdate: () => {}, + onConnectionChange: () => {}, + onResync: () => {}, + }); + + transport.start(); + expect(listenToEventsMock).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/transports.ts b/invofi/apps/frontend/src/lib/live/transports.ts new file mode 100644 index 000000000..e8e9b3a9c --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/transports.ts @@ -0,0 +1,362 @@ +// ── Live transports (issue #221) ───────────────────────────────────────────── +// Two interchangeable sources feed the live dashboard: +// +// 1. `createWebSocketTransport` — connects to a relay (e.g. the keeper's +// SSE/WS endpoint, or any server that mirrors on-chain events) that pushes +// `position_updated`, `yield_calculated`, and `repayment_received` +// envelopes. Reconnects with exponential backoff (first retry within ~1s) +// and gives up to the polling fallback after repeated initial failures. +// +// 2. `createPollingTransport` — the graceful-degradation path. It subscribes +// to the Soroban RPC event stream via the SDK's `listenToEvents` (already +// the project's documented SSE/WS upgrade path — see `apps/sdk/events.ts`) +// and turns on-chain events into the same normalized updates. A periodic +// Supabase resync (owned by the engine) fills any gaps. +// +// Both produce `LivePositionUpdate`s so the reducer and UI are transport-agnostic. + +import { listenToEvents, type ProtocolEventName } from '@invofi/sdk'; +import type { ConnectionStatus, LivePositionUpdate, WsEnvelope } from './types'; +import { stroopsFromWire } from './convert'; + +// ── Shared handle ──────────────────────────────────────────────────────────── + +export interface TransportHandle { + start: () => void; + stop: () => void; +} + +export interface TransportCallbacks { + onUpdate: (update: LivePositionUpdate) => void; + onConnectionChange: (status: ConnectionStatus, detail?: string) => void; + /** Called when a resync of all positions is warranted. */ + onResync: () => void; +} + +// ── WebSocket relay transport ──────────────────────────────────────────────── + +export interface WebSocketTransportOptions extends TransportCallbacks { + url: string; + /** First reconnect delay after a drop. Default 1000ms (reconnect ≤ 5s). */ + reconnectBaseMs?: number; + /** Ceiling for the exponential backoff. Default 30s. */ + maxBackoffMs?: number; + /** How many failed initial connect attempts before giving up to polling. */ + maxReconnectAttempts?: number; + /** Repeated failures after a successful connect before giving up. Default 8. */ + maxRelayFailures?: number; + /** How long to wait for the handshake before treating it as failed. Default 10s. */ + connectTimeoutMs?: number; + /** Called once when the relay is permanently unavailable. */ + onGiveUp: (reason: string) => void; +} + +/** Map a relay envelope to a normalized `LivePositionUpdate`, or null. */ +export function decodeEnvelope(raw: string): LivePositionUpdate | null { + let parsed: WsEnvelope; + try { + parsed = JSON.parse(raw) as WsEnvelope; + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object' || typeof parsed.type !== 'string') return null; + + // Both transports stamp the same received-time shape. + const updatedAt = Date.now(); + try { + switch (parsed.type) { + case 'position_updated': + if (typeof parsed.positionId !== 'string') return null; + return { + kind: 'position_updated', + positionId: parsed.positionId, + fields: parsed.fields ?? {}, + updatedAt, + }; + case 'yield_calculated': + if (typeof parsed.positionId !== 'string' || typeof parsed.apy !== 'number') return null; + return { + kind: 'yield_calculated', + positionId: parsed.positionId, + apy: parsed.apy, + earnedToDate: stroopsFromWire(parsed.earnedToDate), + updatedAt, + }; + case 'repayment_received': + if (typeof parsed.positionId !== 'string') return null; + // The normalized update uses amountRepaid, not progress — accept + // updates that omit progress and reject malformed amounts up front. + if ( + typeof parsed.amountRepaid !== 'string' && + typeof parsed.amountRepaid !== 'number' && + typeof parsed.amountRepaid !== 'bigint' + ) { + return null; + } + return { + kind: 'repayment_received', + positionId: parsed.positionId, + amountRepaid: stroopsFromWire(parsed.amountRepaid), + remaining: + parsed.remaining === undefined ? undefined : stroopsFromWire(parsed.remaining), + fullyRepaid: parsed.fullyRepaid === true, + updatedAt, + }; + default: + return null; + } + } catch { + // A malformed amount must never crash the message handler. + return null; + } +} + +/** + * WebSocket relay client with exponential-backoff reconnection. + * + * Reconnect schedule (base 1s, doubling, capped): 1s → 2s → 4s → … so a + * dropped live connection is restored within ~1–5 seconds (acceptance + * criterion). If the relay never connects within `maxReconnectAttempts` + * attempts, `onGiveUp` fires so the caller can degrade to polling. + */ +export function createWebSocketTransport( + options: WebSocketTransportOptions, +): TransportHandle { + const { + url, + onUpdate, + onConnectionChange, + onResync, + onGiveUp, + reconnectBaseMs = 1_000, + maxBackoffMs = 30_000, + maxReconnectAttempts = 3, + maxRelayFailures = 8, + connectTimeoutMs = 10_000, + } = options; + + let socket: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let connectTimer: ReturnType | null = null; + let stopped = false; + let everConnected = false; + let consecutiveFailures = 0; + + function backoffFor(attempt: number): number { + return Math.min(maxBackoffMs, reconnectBaseMs * 2 ** Math.min(attempt, 6)); + } + + function clearConnectTimer(): void { + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + } + + function connect(): void { + if (stopped) return; + onConnectionChange(everConnected ? 'reconnecting' : 'connecting'); + + let ws: WebSocket; + try { + ws = new WebSocket(url); + } catch { + handleFailure('invalid WebSocket URL'); + return; + } + socket = ws; + + // A stalled handshake fires neither onopen nor onclose — bound it. + connectTimer = setTimeout(() => { + if (stopped || socket !== ws) return; + socket = null; + try { + ws.close(); + } catch { + // already closed + } + handleFailure('connect timed out'); + }, connectTimeoutMs); + + ws.onopen = () => { + if (stopped || socket !== ws) return; + clearConnectTimer(); + everConnected = true; + // Note: the failure counter is NOT reset here. Once live, a drop counts + // against `maxRelayFailures` so a relay that keeps failing hands off to + // polling instead of reconnecting forever. + onConnectionChange('connected'); + // The relay may have missed events while we were offline — resync. + onResync(); + }; + + ws.onmessage = (event: MessageEvent) => { + if (stopped || socket !== ws) return; + const update = decodeEnvelope(String(event.data)); + if (update) onUpdate(update); + }; + + ws.onerror = () => { + // onclose follows — the close handler owns retry logic. + }; + + ws.onclose = () => { + if (stopped || socket !== ws) return; + clearConnectTimer(); + socket = null; + handleFailure('connection closed'); + }; + } + + function handleFailure(reason: string): void { + if (stopped) return; + + if (everConnected) { + // We were live and dropped — reconnect with backoff, but only so far: a + // relay that stays down must hand off to the polling fallback. + consecutiveFailures += 1; + if (consecutiveFailures >= maxRelayFailures) { + onGiveUp(reason); + return; + } + const backoff = backoffFor(consecutiveFailures - 1); + onConnectionChange('reconnecting', `retry in ${Math.round(backoff / 1000)}s`); + reconnectTimer = setTimeout(connect, backoff); + return; + } + + // Never connected yet — a few attempts, then hand off to polling. + consecutiveFailures += 1; + if (consecutiveFailures >= maxReconnectAttempts) { + onGiveUp(reason); + return; + } + onConnectionChange('connecting', `attempt ${consecutiveFailures + 1}`); + reconnectTimer = setTimeout(connect, backoffFor(consecutiveFailures)); + } + + function stop(): void { + stopped = true; + clearConnectTimer(); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (socket) { + socket.onopen = socket.onclose = socket.onmessage = socket.onerror = null; + try { + socket.close(); + } catch { + // already closed + } + socket = null; + } + } + + return { start: connect, stop }; +} + +// ── Soroban event-stream polling transport (fallback) ─────────────────────── + +const POLLING_EVENT_TYPES: ProtocolEventName[] = [ + 'off_acc', + 'off_rej', + 'off_wdr', + 'off_def', + 'inv_rep', + 'pos_mint', +]; + +export interface PollingTransportOptions extends TransportCallbacks { + /** Contract IDs to watch. Empty → rely on the engine's Supabase resync. */ + contractIds: string[]; + rpcUrl: string; + networkPassphrase: string; + /** How often to poll the RPC. Default 5000ms (one Stellar ledger ≈ 5s). */ + pollIntervalMs?: number; +} + +/** + * Graceful-degradation transport: watches the Soroban RPC event stream for the + * user's protocol events and maps them to the same normalized updates. + * + * `inv_rep` maps directly to a `repayment_received` update for instant feedback; + * every other mutation triggers a resync because status transitions need the + * full row. A periodic Supabase resync (in the engine) is the safety net. + */ +export function createPollingTransport( + options: PollingTransportOptions, +): TransportHandle { + const { + contractIds, + rpcUrl, + networkPassphrase, + onUpdate, + onConnectionChange, + onResync, + pollIntervalMs = 5_000, + } = options; + + let stopListening: (() => void) | null = null; + let stopped = false; + let resyncPending: ReturnType | null = null; + + // One poll cycle can deliver a batch of events — coalesce them into a single + // resync request (the engine's in-flight guard also dedupes concurrently). + function requestResync(): void { + if (stopped) return; + if (resyncPending) return; + resyncPending = setTimeout(() => { + resyncPending = null; + onResync(); + }, 0); + } + + function start(): void { + if (stopped) return; + onConnectionChange('polling'); + if (!contractIds || contractIds.length === 0) return; // resync-only fallback + stopListening = listenToEvents({ + rpcUrl, + networkPassphrase, + contractIds, + eventTypes: POLLING_EVENT_TYPES, + pollIntervalMs, + onEvent(event) { + if (stopped) return; + if (event.type === 'inv_rep') { + onUpdate({ + kind: 'repayment_received', + positionId: event.subjectId, + amountRepaid: event.data.amount, + fullyRepaid: event.data.fullyRepaid, + updatedAt: Date.now(), + }); + // Instant feedback for repayments; other mutations need a resync. + return; + } + requestResync(); + }, + onError(error) { + // The SDK retries with back-off internally; surface the failure so the + // UI's connection detail shows why live data may be stale. + if (stopped) return; + onConnectionChange('polling', `poll error: ${error.message}`); + }, + }); + } + + function stop(): void { + stopped = true; + if (resyncPending) { + clearTimeout(resyncPending); + resyncPending = null; + } + if (stopListening) { + stopListening(); + stopListening = null; + } + } + + return { start, stop }; +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/types.ts b/invofi/apps/frontend/src/lib/live/types.ts new file mode 100644 index 000000000..86c4c992f --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/types.ts @@ -0,0 +1,134 @@ +// ── Live portfolio types (issue #221) ─────────────────────────────────────── +// Shared contracts for the WebSocket + polling live dashboard. These are +// framework-agnostic so the transport, engine, and reducer layers stay +// unit-testable without React. + +import type { Currency, FinancingOffer } from '@invofi/sdk'; + +/** Which live transport is currently backing the dashboard. */ +export type LiveTransport = 'websocket' | 'polling'; + +/** + * Connection lifecycle of the live stream. + * + * | Value | Meaning | + * |---------------|---------------------------------------------------------------| + * | `connecting` | Establishing the initial WebSocket connection | + * | `connected` | Streaming live updates over WebSocket | + * | `reconnecting`| WebSocket dropped — retrying with exponential backoff | + * | `polling` | WebSocket unavailable — Soroban event stream + Supabase poll | + * | `offline` | No live source at all (no wallet, no data) | + */ +export type ConnectionStatus = + | 'connecting' + | 'connected' + | 'reconnecting' + | 'polling' + | 'offline'; + +/** + * A portfolio position enriched with live-computed values. Everything the + * dashboard renders derives from this single shape, so a full resync and an + * incremental streamed update produce identical rows. + */ +export interface LivePosition extends FinancingOffer { + /** Annualized yield as a percentage (agreed rate scaled to a year). */ + apy: number; + /** Yield accrued so far, in stroops (principal × rate × elapsed/duration). */ + earnedToDate: bigint; + /** Principal + total agreed yield, in stroops. */ + totalDue: bigint; + /** Outstanding claim in stroops (totalDue − amount_repaid, floor 0). */ + remaining: bigint; + /** Repayment progress as a ratio 0..1 (streaming progress bar). */ + repaymentProgress: number; + /** Current USD value of the outstanding claim. */ + liveValueUsd: number; + /** Epoch ms of the last update that touched this position. */ + updatedAt: number; +} + +/** Partial offer fields delivered by a `position_updated` event. */ +export type PositionUpdatedFields = Partial< + Pick +> & { + /** Amounts may arrive as strings over the wire (bigints don't survive JSON). */ + amount?: bigint | string; + amount_repaid?: bigint | string; +}; + +/** + * Normalized live update delivered to the reducer. Every transport (WebSocket + * relay or Soroban event polling) produces these, so the UI code never cares + * where an update came from. + */ +export type LivePositionUpdate = + | { + kind: 'position_updated'; + positionId: string; + fields: PositionUpdatedFields; + updatedAt?: number; + } + | { + kind: 'yield_calculated'; + positionId: string; + apy: number; + earnedToDate: bigint; + updatedAt?: number; + } + | { + kind: 'repayment_received'; + positionId: string; + /** Incremental amount of this repayment, in stroops. */ + amountRepaid: bigint; + /** + * Cumulative outstanding claim (totalDue − repaid) when the relay + * provides it. When present the reducer derives `amount_repaid` from this + * monotonic value so replayed deliveries can't double-count. + */ + remaining?: bigint; + fullyRepaid: boolean; + updatedAt?: number; + }; + +/** + * Wire protocol for a WebSocket relay (see `createWebSocketTransport`). + * The relay forwards on-chain events as one of these envelopes: + * + * ```jsonc + * { "type": "position_updated", "positionId": "off_…", "fields": { "status": "Financed", "funded_at": 1713… } } + * { "type": "yield_calculated", "positionId": "off_…", "apy": 12.5, "earnedToDate": "1234567" } + * { "type": "repayment_received", "positionId": "off_…", "amountRepaid": "1000000", "remaining": "5000000", + * "progress": 0.2, "fullyRepaid": false } + * ``` + * + * Amounts are stroops and may arrive as JSON numbers or strings (bigints do + * not survive JSON) — the transport normalizes them with `toStroopsBigInt`. + */ +export type WsEnvelope = + | { type: 'position_updated'; positionId: string; fields: PositionUpdatedFields } + | { + type: 'yield_calculated'; + positionId: string; + apy: number; + earnedToDate: bigint | number | string; + } + | { + type: 'repayment_received'; + positionId: string; + amountRepaid: bigint | number | string; + remaining: bigint | number | string; + progress: number; + fullyRepaid: boolean; + }; + +/** Value object describing the current connection for the status pill. */ +export interface ConnectionInfo { + status: ConnectionStatus; + transport: LiveTransport; + /** Human-readable detail (e.g. "retry in 2s" or "relay unavailable"). */ + detail?: string; +} + +/** Lookup keyed by currency for USD pricing. */ +export type PriceFor = (currency: Currency) => number; \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/yield.test.ts b/invofi/apps/frontend/src/lib/live/yield.test.ts new file mode 100644 index 000000000..e8eb19e0f --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/yield.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + offerApy, + remainingStroops, + repaymentProgress, + totalDueStroops, + totalYieldStroops, + yieldEarnedStroops, +} from './yield'; + +const DAY = 86_400; +const offer = { + amount: 1_000_000_000n, // 100 XLM + amount_repaid: 0n, + interest_rate: 500, // 5.00% + duration: 30 * DAY, + funded_at: 1_000_000, +}; + +describe('yield / repayment math', () => { + it('computes simple-interest yield and total due', () => { + expect(totalYieldStroops(offer)).toBe(50_000_000n); // 5% of principal + expect(totalDueStroops(offer)).toBe(1_050_000_000n); + }); + + it('annualizes the agreed rate into an APY', () => { + // 5% over 30 days → ~60.83% over a 365-day year. + expect(offerApy(offer)).toBeCloseTo(60.83, 1); + expect(offerApy({ interest_rate: 500, duration: 365 * DAY })).toBeCloseTo(5, 5); + expect(offerApy({ interest_rate: 500, duration: 0 })).toBe(0); + }); + + it('accrues yield linearly from funded_at, capped at the total', () => { + expect(yieldEarnedStroops(offer, offer.funded_at)).toBe(0n); + expect(yieldEarnedStroops(offer, offer.funded_at + 15 * DAY)).toBe(25_000_000n); + expect(yieldEarnedStroops(offer, offer.funded_at + 60 * DAY)).toBe(50_000_000n); // capped + expect(yieldEarnedStroops({ ...offer, funded_at: 0 })).toBe(0n); + expect(yieldEarnedStroops({ ...offer, duration: 0 })).toBe(0n); + }); + + it('keeps accrual arithmetic in bigint for amounts above Number.MAX_SAFE_INTEGER', () => { + // 3_000_000_000_000_000n stroops (~300M XLM) — Number() would round it. + const bigOffer = { + amount: 3_000_000_000_000_000n, + amount_repaid: 0n, + interest_rate: 500, + duration: 30 * DAY, + funded_at: 1_000_000, + }; + // Halfway: exact bigint division, no float rounding. + expect(yieldEarnedStroops(bigOffer, bigOffer.funded_at + 15 * DAY)).toBe(75_000_000_000_000n); + expect(yieldEarnedStroops(bigOffer, bigOffer.funded_at + 30 * DAY)).toBe(150_000_000_000_000n); + }); + + it('tracks remaining and repayment progress', () => { + expect(remainingStroops(offer)).toBe(1_050_000_000n); + expect(repaymentProgress(offer)).toBe(0); + + const halfRepaid = { ...offer, amount_repaid: 525_000_000n }; + expect(remainingStroops(halfRepaid)).toBe(525_000_000n); + expect(repaymentProgress(halfRepaid)).toBeCloseTo(0.5, 5); + + const overpaid = { ...offer, amount_repaid: 2_000_000_000n }; + expect(remainingStroops(overpaid)).toBe(0n); + expect(repaymentProgress(overpaid)).toBe(1); + }); +}); \ No newline at end of file diff --git a/invofi/apps/frontend/src/lib/live/yield.ts b/invofi/apps/frontend/src/lib/live/yield.ts new file mode 100644 index 000000000..27a62a324 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/yield.ts @@ -0,0 +1,76 @@ +// ── Yield / repayment math (issue #221) ────────────────────────────────────── +// Pure helpers shared by the reducer and the live engine. The contract uses +// simple interest: yield = principal × interest_rate / 10000, accreting +// linearly from `funded_at` across `duration` seconds. All amounts are stroops. + +import type { FinancingOffer } from '@invofi/sdk'; + +export const SECONDS_PER_YEAR = 365 * 86_400; + +/** Total agreed yield in stroops: principal × rate (simple interest). */ +export function totalYieldStroops( + offer: Pick, +): bigint { + return (offer.amount * BigInt(offer.interest_rate)) / 10_000n; +} + +/** Amount the borrower owes in total: principal + agreed yield. */ +export function totalDueStroops( + offer: Pick, +): bigint { + return offer.amount + totalYieldStroops(offer); +} + +/** Outstanding claim in stroops (floored at 0). */ +export function remainingStroops( + offer: Pick, +): bigint { + const due = totalDueStroops(offer); + const repaid = offer.amount_repaid; + return due > repaid ? due - repaid : 0n; +} + +/** Repayment progress as a ratio 0..1 for the streaming progress bar. */ +export function repaymentProgress( + offer: Pick, +): number { + const due = totalDueStroops(offer); + if (due <= 0n) return 0; + const ratio = Number(offer.amount_repaid) / Number(due); + return Math.min(1, Math.max(0, ratio)); +} + +/** + * Annualized yield as a percentage. The agreed rate covers `duration` seconds, + * so scaling it to a 365-day year gives a comparable APY across positions. + */ +export function offerApy( + offer: Pick, +): number { + if (offer.duration <= 0 || offer.interest_rate <= 0) return 0; + return (offer.interest_rate / 10_000) * (SECONDS_PER_YEAR / offer.duration) * 100; +} + +/** + * Yield accrued so far, in stroops. Linear from `funded_at`, capped at the + * total agreed yield. Returns 0 for offers that were never funded. + */ +export function yieldEarnedStroops( + offer: Pick, + nowSecs: number = Date.now() / 1000, +): bigint { + if (offer.funded_at <= 0 || offer.duration <= 0) return 0n; + const total = totalYieldStroops(offer); + if (total <= 0n) return 0n; + const elapsed = Math.max(0, nowSecs - offer.funded_at); + if (elapsed >= offer.duration) return total; + // Stay in bigint — Number(total) loses precision for valid large on-chain + // amounts, and the floor must apply to the integer ratio, not a float. + const elapsedSecs = BigInt(Math.floor(elapsed)); + return (total * elapsedSecs) / BigInt(offer.duration); +} + +/** Whether an offer is actively deploying capital (its yield accrues). */ +export function isActiveOffer(offer: Pick): boolean { + return offer.status === 'Accepted' || offer.status === 'Financed'; +} \ No newline at end of file diff --git a/invofi/apps/frontend/vitest.config.ts b/invofi/apps/frontend/vitest.config.ts index 3b3217ecf..a416a1d0b 100644 --- a/invofi/apps/frontend/vitest.config.ts +++ b/invofi/apps/frontend/vitest.config.ts @@ -18,6 +18,13 @@ export default defineConfig({ 'src/lib/utils.ts', 'src/lib/csv.ts', 'src/lib/constants.ts', + 'src/lib/live/throttle.ts', + 'src/lib/live/yield.ts', + 'src/lib/live/prices.ts', + 'src/lib/live/reducer.ts', + 'src/lib/live/transports.ts', + 'src/lib/live/engine.ts', + 'src/lib/live/convert.ts', 'src/hooks/useDebounce.ts', 'src/hooks/useLocalStorage.ts', 'src/hooks/useMediaQuery.ts',