From aa13e400cdbbf6d2a2ac74b7d8f03ecd7a08d735 Mon Sep 17 00:00:00 2001 From: fadesany Date: Tue, 18 Aug 2026 20:03:09 +0000 Subject: [PATCH 1/2] feat(frontend): websocket-based live portfolio dashboard with streaming updates Replace the static portfolio view with a live dashboard that streams position, yield, and repayment updates in real time. - LivePortfolioProvider (React context + useReducer) owns portfolio state and re-derives USD value, APY, earned-to-date, and repayment progress on every update - WebSocket relay transport (NEXT_PUBLIC_WS_URL) with exponential-backoff reconnection (first retry within ~1s); graceful degradation to a Soroban event-stream (SDK listenToEvents) + Supabase polling fallback - Per-position throttle caps updates at 1/sec to avoid UI thrash - Connection status pill + streaming repayment progress bars on the portfolio page; yields accrue continuously via a 1s ticker - Wire protocol: position_updated / yield_calculated / repayment_received - 40 new unit tests (throttle, yield, prices, reducer, transports, engine) - Documents NEXT_PUBLIC_WS_URL and NEXT_PUBLIC_XLM_USD_PRICE Closes #221 --- README.md | 9 + docs/08-environment-variables.md | 4 + invofi/apps/frontend/.env.local.example | 28 ++ .../frontend/src/app/portfolio/layout.tsx | 7 +- .../apps/frontend/src/app/portfolio/page.tsx | 268 +++++++++------- .../components/portfolio/ConnectionStatus.tsx | 66 ++++ .../portfolio/LivePortfolioProvider.tsx | 173 +++++++++++ .../portfolio/RepaymentProgress.tsx | 37 +++ invofi/apps/frontend/src/lib/live/config.ts | 22 ++ .../frontend/src/lib/live/convert.test.ts | 25 ++ invofi/apps/frontend/src/lib/live/convert.ts | 21 ++ .../apps/frontend/src/lib/live/engine.test.ts | 114 +++++++ invofi/apps/frontend/src/lib/live/engine.ts | 176 +++++++++++ .../apps/frontend/src/lib/live/prices.test.ts | 37 +++ invofi/apps/frontend/src/lib/live/prices.ts | 72 +++++ .../frontend/src/lib/live/reducer.test.ts | 164 ++++++++++ invofi/apps/frontend/src/lib/live/reducer.ts | 160 ++++++++++ .../frontend/src/lib/live/throttle.test.ts | 79 +++++ invofi/apps/frontend/src/lib/live/throttle.ts | 62 ++++ .../frontend/src/lib/live/transports.test.ts | 261 ++++++++++++++++ .../apps/frontend/src/lib/live/transports.ts | 287 ++++++++++++++++++ invofi/apps/frontend/src/lib/live/types.ts | 128 ++++++++ .../apps/frontend/src/lib/live/yield.test.ts | 53 ++++ invofi/apps/frontend/src/lib/live/yield.ts | 73 +++++ invofi/apps/frontend/vitest.config.ts | 7 + 25 files changed, 2225 insertions(+), 108 deletions(-) create mode 100644 invofi/apps/frontend/.env.local.example create mode 100644 invofi/apps/frontend/src/components/portfolio/ConnectionStatus.tsx create mode 100644 invofi/apps/frontend/src/components/portfolio/LivePortfolioProvider.tsx create mode 100644 invofi/apps/frontend/src/components/portfolio/RepaymentProgress.tsx create mode 100644 invofi/apps/frontend/src/lib/live/config.ts create mode 100644 invofi/apps/frontend/src/lib/live/convert.test.ts create mode 100644 invofi/apps/frontend/src/lib/live/convert.ts create mode 100644 invofi/apps/frontend/src/lib/live/engine.test.ts create mode 100644 invofi/apps/frontend/src/lib/live/engine.ts create mode 100644 invofi/apps/frontend/src/lib/live/prices.test.ts create mode 100644 invofi/apps/frontend/src/lib/live/prices.ts create mode 100644 invofi/apps/frontend/src/lib/live/reducer.test.ts create mode 100644 invofi/apps/frontend/src/lib/live/reducer.ts create mode 100644 invofi/apps/frontend/src/lib/live/throttle.test.ts create mode 100644 invofi/apps/frontend/src/lib/live/throttle.ts create mode 100644 invofi/apps/frontend/src/lib/live/transports.test.ts create mode 100644 invofi/apps/frontend/src/lib/live/transports.ts create mode 100644 invofi/apps/frontend/src/lib/live/types.ts create mode 100644 invofi/apps/frontend/src/lib/live/yield.test.ts create mode 100644 invofi/apps/frontend/src/lib/live/yield.ts 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..a8a2fe222 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,115 @@ 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, + ); 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 + Number(yield_) / STROOPS_PER_XLM; }, 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 +408,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 +463,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(4)} XLM +

+

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

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

No financing offers yet.

@@ -416,50 +511,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 +523,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..6cbd8dd0c --- /dev/null +++ b/invofi/apps/frontend/src/components/portfolio/LivePortfolioProvider.tsx @@ -0,0 +1,173 @@ +'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 } = await supabase + .from('financing_offers') + .select('*, invoice:invoices(*)') + .eq('lender_id', user.id) + .order('created_at', { ascending: false }); + 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.ts b/invofi/apps/frontend/src/lib/live/config.ts new file mode 100644 index 000000000..426683b55 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/config.ts @@ -0,0 +1,22 @@ +// ── 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_RPC_URL = process.env.NEXT_PUBLIC_RPC_URL ?? 'https://soroban-testnet.stellar.org'; +export const LIVE_WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? ''; +export const LIVE_NETWORK = (process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet') as + | 'testnet' + | 'mainnet'; +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..8ddd9ee23 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/convert.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { 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('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); + }); +}); \ 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..ef814d00e --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/convert.ts @@ -0,0 +1,21 @@ +// ── 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') return BigInt(Math.trunc(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); +} \ 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..74c765a6a --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/engine.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FinancingOffer } from '@invofi/sdk'; +import { LivePortfolioEngine } from './engine'; + +const { wsStartMock, wsStopMock, pollingStartMock, pollingStopMock } = vi.hoisted(() => ({ + wsStartMock: vi.fn(), + wsStopMock: vi.fn(), + pollingStartMock: vi.fn(), + pollingStopMock: vi.fn(), +})); + +vi.mock('./transports', () => ({ + createWebSocketTransport: vi.fn(() => ({ start: wsStartMock, stop: wsStopMock })), + createPollingTransport: vi.fn(() => ({ start: pollingStartMock, stop: pollingStopMock })), +})); + +const DAY = 86_400; +const activeOffer: FinancingOffer = { + id: 'off_1', + invoice_id: 'inv_1', + lender: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + amount: 10_000_000n, + currency: 'USDC', + interest_rate: 500, + duration: 30 * DAY, + amount_repaid: 0n, + status: 'Financed', + funded_at: 1_000_000, +}; + +describe('LivePortfolioEngine', () => { + beforeEach(() => { + vi.useFakeTimers(); + wsStartMock.mockClear(); + wsStopMock.mockClear(); + pollingStartMock.mockClear(); + pollingStopMock.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 () => [activeOffer], + 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']); + 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(); + engine.stop(); + }); + + it('throttles yield accrual to one update per position per second', async () => { + const updates: Array<{ kind: string; positionId: string }> = []; + const engine = new LivePortfolioEngine({ + wsUrl: null, + contractIds: [], + rpcUrl: 'https://rpc', + networkPassphrase: 'testnet', + fetchPositions: async () => [activeOffer], + onPositions: () => {}, + onUpdate: update => updates.push(update), + onConnectionChange: () => {}, + throttleMs: 1_000, + yieldTickMs: 250, + }); + + await engine.start(); + expect(updates).toHaveLength(0); + + // First accrual tick lands at 250ms; the throttle delivers at +1000ms. + await vi.advanceTimersByTimeAsync(1_300); + expect(updates).toHaveLength(1); + expect(updates[0].kind).toBe('yield_calculated'); + expect(updates[0].positionId).toBe('off_1'); + + // One more second → exactly one more delivery (never more than 1/sec). + await vi.advanceTimersByTimeAsync(1_300); + expect(updates).toHaveLength(2); + + engine.stop(); + }); +}); \ 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..acefa1033 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/engine.ts @@ -0,0 +1,176 @@ +// ── 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[] = []; + private started = false; + private stopped = false; + private degradedToPolling = false; + + constructor(options: LivePortfolioEngineOptions) { + this.opts = options; + } + + /** 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, + fetchPositions, + onPositions, + onUpdate, + onConnectionChange, + throttleMs = 1_000, + resyncIntervalMs = 10_000, + yieldTickMs = 1_000, + } = this.opts; + + this.throttle = createPerKeyThrottle(throttleMs, (positionId, update) => { + onUpdate({ ...update, positionId }); + }); + + const resync = async (): Promise => { + if (this.stopped) return; + try { + const offers = await fetchPositions(); + if (this.stopped) return; + this.latestOffers = offers; + onPositions(offers); + } catch { + // A failed resync is non-fatal — the live stream keeps the last state. + } + }; + + if (wsUrl) { + this.ws = createWebSocketTransport({ + url: wsUrl, + onUpdate: update => this.throttle?.(update.positionId, update), + onConnectionChange: (status, detail) => onConnectionChange(status, 'websocket', detail), + onResync: () => void 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. + this.yieldTimer = setInterval(() => { + if (this.stopped) return; + const nowSecs = Date.now() / 1000; + for (const offer of this.latestOffers) { + if (!isActiveOffer(offer)) continue; + this.throttle?.(offer.id, { + kind: 'yield_calculated', + positionId: offer.id, + apy: offerApy(offer), + earnedToDate: yieldEarnedStroops(offer, nowSecs), + updatedAt: Date.now(), + }); + } + }, yieldTickMs); + + // Periodic safety-net resync (both modes) so brand-new positions appear. + this.resyncTimer = setInterval(() => void resync(), resyncIntervalMs); + + await 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?.(update.positionId, update), + onConnectionChange: (status, detail) => + this.opts.onConnectionChange(status, 'polling', detail), + onResync: () => this.resyncNow(), + }); + this.polling.start(); + } + + /** Force an immediate full resync (used by refresh buttons + auth changes). */ + resyncNow(): void { + if (this.stopped) return; + const { fetchPositions, onPositions } = this.opts; + fetchPositions() + .then(offers => { + if (this.stopped) return; + this.latestOffers = offers; + onPositions(offers); + }) + .catch(() => { + // Non-fatal — keep the last known state. + }); + } + + /** Tear down every timer and transport. */ + stop(): void { + this.stopped = true; + 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; + this.throttle?.stop(); + this.throttle = null; + } +} \ 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..869035db0 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/reducer.test.ts @@ -0,0 +1,164 @@ +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('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('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..57d7195b3 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/reducer.ts @@ -0,0 +1,160 @@ +// ── 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 { stroopsFromWire } 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: stroopsFromWire(update.fields.amount ?? position.amount), + amount_repaid: stroopsFromWire(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': { + const merged = { + ...position, + amount_repaid: position.amount_repaid + update.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..f4bb8bc17 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/throttle.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createPerKeyThrottle } from './throttle'; + +describe('createPerKeyThrottle', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('coalesces bursts per key and delivers the latest value', () => { + vi.useFakeTimers(); + const delivered: Array<[string, number]> = []; + const throttle = createPerKeyThrottle(1_000, (key, value) => delivered.push([key, value])); + + throttle('a', 1); + throttle('a', 2); + throttle('a', 3); + throttle('b', 10); + + vi.advanceTimersByTime(1_000); + + expect(delivered).toEqual([ + ['a', 3], + ['b', 10], + ]); + }); + + it('delivers at most once per interval per key', () => { + vi.useFakeTimers(); + const delivered: string[] = []; + const throttle = createPerKeyThrottle(1_000, key => delivered.push(key)); + + for (let i = 0; i < 5; i++) { + throttle('a', String(i)); + vi.advanceTimersByTime(100); + } + vi.advanceTimersByTime(1_000); + + expect(delivered).toEqual(['a']); + }); + + 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 fires now, b is not yet due + throttle('a', '3'); // a is throttled again until the next window + vi.advanceTimersByTime(1_000); // b fires, then a (coalesced) fires + + expect(delivered).toEqual(['a', 'b', 'a']); + }); + + it('flush delivers pending values immediately and prevents double delivery', () => { + vi.useFakeTimers(); + const delivered: number[] = []; + const throttle = createPerKeyThrottle(1_000, (_key, value) => delivered.push(value)); + + throttle('a', 1); + throttle.flush(); + vi.advanceTimersByTime(1_000); + + expect(delivered).toEqual([1]); + }); + + 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); + throttle.stop(); + vi.advanceTimersByTime(2_000); + + expect(delivered).toEqual([]); + }); +}); \ 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..9d2d47ee8 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/throttle.ts @@ -0,0 +1,62 @@ +// ── 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, so we coalesce by key: while a key has a pending timer, +// newer values replace the pending one, and the *latest* is delivered when the +// timer fires. + +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; + pending.set(key, value); + if (timers.has(key)) return; // a delivery is already scheduled + timers.set( + key, + setTimeout(() => { + timers.delete(key); + const latest = pending.get(key); + if (latest === undefined) return; + pending.delete(key); + deliver(key, latest); + }, intervalMs), + ); + } + + dispatch.flush = () => { + if (stopped) return; + 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..64a9b5fd3 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/transports.test.ts @@ -0,0 +1,261 @@ +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', () => { + expect( + decodeEnvelope( + JSON.stringify({ type: 'position_updated', positionId: 'off_1', fields: { status: 'Financed' } }), + ), + ).toEqual({ kind: 'position_updated', positionId: 'off_1', fields: { status: 'Financed' } }); + }); + + 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 }); + }); + + it('decodes repayment_received', () => { + 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, + fullyRepaid: false, + }); + }); + + 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 + }); +}); + +describe('createPollingTransport', () => { + beforeEach(() => { + listenToEventsMock.mockClear(); + }); + + 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'], + }), + ); + }); + + it('maps inv_rep events to repayment updates and requests a 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(1); + }); + + 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..fba944d6c --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/transports.ts @@ -0,0 +1,287 @@ +// ── 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; + /** 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; + + switch (parsed.type) { + case 'position_updated': + if (typeof parsed.positionId !== 'string') return null; + return { + kind: 'position_updated', + positionId: parsed.positionId, + fields: parsed.fields ?? {}, + }; + 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), + }; + case 'repayment_received': + if (typeof parsed.positionId !== 'string' || typeof parsed.progress !== 'number') { + return null; + } + return { + kind: 'repayment_received', + positionId: parsed.positionId, + amountRepaid: stroopsFromWire(parsed.amountRepaid), + fullyRepaid: parsed.fullyRepaid === true, + }; + default: + 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, + } = options; + + let socket: WebSocket | null = null; + let reconnectTimer: 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 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; + + ws.onopen = () => { + if (stopped || socket !== ws) return; + everConnected = true; + consecutiveFailures = 0; + 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; + socket = null; + handleFailure('connection closed'); + }; + } + + function handleFailure(reason: string): void { + if (stopped) return; + + if (everConnected) { + // We were live and dropped — reconnect with backoff. + const backoff = backoffFor(consecutiveFailures); + 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; + 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; + + 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(), + }); + } + onResync(); + }, + onError() { + // Retries with back-off are handled inside the SDK; the periodic + // Supabase resync keeps the dashboard correct meanwhile. + }, + }); + } + + function stop(): void { + stopped = true; + 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..e26a8c31f --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/types.ts @@ -0,0 +1,128 @@ +// ── 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; + 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..8d88e2821 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/yield.test.ts @@ -0,0 +1,53 @@ +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('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..ec25d4ef7 --- /dev/null +++ b/invofi/apps/frontend/src/lib/live/yield.ts @@ -0,0 +1,73 @@ +// ── 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); + const ratio = Math.min(1, elapsed / offer.duration); + return BigInt(Math.floor(Number(total) * ratio)); +} + +/** 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', From 7680b4350364585c89fee88bc001c358025ed2ce Mon Sep 17 00:00:00 2001 From: fadesany Date: Tue, 18 Aug 2026 22:24:46 +0000 Subject: [PATCH 2/2] fix(frontend): address code review findings on live portfolio dashboard - compute realized yield per currency instead of summing raw stroops - dispatch an error action when financing offers fail to load - derive default RPC URL from the configured network when unset - reject unsafe stroops conversions and add a non-throwing safe variant - keep accrual arithmetic in bigint to avoid precision loss - throttle leading-edge delivery and cancel armed timers on flush - harden websocket transport with connect timeout and relay give-up - make replayed repayments idempotent via cumulative remaining - add config and reducer tests and update existing live tests --- .../apps/frontend/src/app/portfolio/page.tsx | 6 +- .../portfolio/LivePortfolioProvider.tsx | 8 +- .../apps/frontend/src/lib/live/config.test.ts | 47 +++++ invofi/apps/frontend/src/lib/live/config.ts | 12 +- .../frontend/src/lib/live/convert.test.ts | 17 +- invofi/apps/frontend/src/lib/live/convert.ts | 19 +- .../apps/frontend/src/lib/live/engine.test.ts | 154 +++++++++++++-- invofi/apps/frontend/src/lib/live/engine.ts | 90 +++++---- .../frontend/src/lib/live/reducer.test.ts | 95 +++++++++ invofi/apps/frontend/src/lib/live/reducer.ts | 17 +- .../frontend/src/lib/live/throttle.test.ts | 50 +++-- invofi/apps/frontend/src/lib/live/throttle.ts | 36 ++-- .../frontend/src/lib/live/transports.test.ts | 185 +++++++++++++++++- .../apps/frontend/src/lib/live/transports.ts | 143 ++++++++++---- invofi/apps/frontend/src/lib/live/types.ts | 6 + .../apps/frontend/src/lib/live/yield.test.ts | 14 ++ invofi/apps/frontend/src/lib/live/yield.ts | 7 +- 17 files changed, 771 insertions(+), 135 deletions(-) create mode 100644 invofi/apps/frontend/src/lib/live/config.test.ts diff --git a/invofi/apps/frontend/src/app/portfolio/page.tsx b/invofi/apps/frontend/src/app/portfolio/page.tsx index a8a2fe222..0e41c59c3 100644 --- a/invofi/apps/frontend/src/app/portfolio/page.tsx +++ b/invofi/apps/frontend/src/app/portfolio/page.tsx @@ -380,9 +380,11 @@ export default function PortfolioPage() { (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 yield_ = o.totalDue - o.amount; - return sum + Number(yield_) / STROOPS_PER_XLM; + return sum + stroopsToUsd(yield_, o.currency); }, 0); const exportOffersCsv = () => { @@ -484,7 +486,7 @@ export default function PortfolioPage() { {repaid.length > 0 && (

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

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

diff --git a/invofi/apps/frontend/src/components/portfolio/LivePortfolioProvider.tsx b/invofi/apps/frontend/src/components/portfolio/LivePortfolioProvider.tsx index 6cbd8dd0c..e1a57973d 100644 --- a/invofi/apps/frontend/src/components/portfolio/LivePortfolioProvider.tsx +++ b/invofi/apps/frontend/src/components/portfolio/LivePortfolioProvider.tsx @@ -84,11 +84,17 @@ export function LivePortfolioProvider({ children }: { children: React.ReactNode const fetchPositions = useCallback(async (): Promise => { const { data: { user } } = await supabase.auth.getUser(); if (!user) return []; - const { data } = await supabase + 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 => ({ 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 index 426683b55..e53aaaf07 100644 --- a/invofi/apps/frontend/src/lib/live/config.ts +++ b/invofi/apps/frontend/src/lib/live/config.ts @@ -5,11 +5,19 @@ import { Networks } from '@invofi/sdk'; -export const LIVE_RPC_URL = process.env.NEXT_PUBLIC_RPC_URL ?? 'https://soroban-testnet.stellar.org'; -export const LIVE_WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? ''; 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; diff --git a/invofi/apps/frontend/src/lib/live/convert.test.ts b/invofi/apps/frontend/src/lib/live/convert.test.ts index 8ddd9ee23..1f2cb4ef1 100644 --- a/invofi/apps/frontend/src/lib/live/convert.test.ts +++ b/invofi/apps/frontend/src/lib/live/convert.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { stroopsFromWire } from './convert'; +import { safeStroopsFromWire, stroopsFromWire } from './convert'; describe('stroopsFromWire', () => { it('passes bigints and numbers through as stroops', () => { @@ -7,6 +7,14 @@ describe('stroopsFromWire', () => { 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); @@ -22,4 +30,11 @@ describe('stroopsFromWire', () => { 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 index ef814d00e..e2c80846d 100644 --- a/invofi/apps/frontend/src/lib/live/convert.ts +++ b/invofi/apps/frontend/src/lib/live/convert.ts @@ -11,11 +11,28 @@ 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') return BigInt(Math.trunc(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 index 74c765a6a..93c859503 100644 --- a/invofi/apps/frontend/src/lib/live/engine.test.ts +++ b/invofi/apps/frontend/src/lib/live/engine.test.ts @@ -1,32 +1,48 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { FinancingOffer } from '@invofi/sdk'; import { LivePortfolioEngine } from './engine'; - -const { wsStartMock, wsStopMock, pollingStartMock, pollingStopMock } = vi.hoisted(() => ({ +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: vi.fn(() => ({ start: wsStartMock, stop: wsStopMock })), - createPollingTransport: vi.fn(() => ({ start: pollingStartMock, stop: pollingStopMock })), + createWebSocketTransport: (opts: unknown) => { + createWsMock(opts); + return { start: wsStartMock, stop: wsStopMock }; + }, + createPollingTransport: (opts: unknown) => { + createPollingMock(opts); + return { start: pollingStartMock, stop: pollingStopMock }; + }, })); -const DAY = 86_400; -const activeOffer: FinancingOffer = { +/** 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: 30 * DAY, + duration: 40, amount_repaid: 0n, status: 'Financed', - funded_at: 1_000_000, -}; + funded_at: nowSecs, +}); describe('LivePortfolioEngine', () => { beforeEach(() => { @@ -35,6 +51,8 @@ describe('LivePortfolioEngine', () => { wsStopMock.mockClear(); pollingStartMock.mockClear(); pollingStopMock.mockClear(); + createWsMock.mockClear(); + createPollingMock.mockClear(); }); afterEach(() => { @@ -48,7 +66,7 @@ describe('LivePortfolioEngine', () => { contractIds: ['registry'], rpcUrl: 'https://rpc', networkPassphrase: 'testnet', - fetchPositions: async () => [activeOffer], + fetchPositions: async () => [makeActiveOffer(Date.now() / 1000)], onPositions: () => {}, onUpdate: () => {}, onConnectionChange: (connection, transport) => statuses.push([connection, transport]), @@ -59,6 +77,11 @@ describe('LivePortfolioEngine', () => { 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(); }); @@ -78,17 +101,46 @@ describe('LivePortfolioEngine', () => { 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('throttles yield accrual to one update per position per second', async () => { - const updates: Array<{ kind: string; positionId: string }> = []; + 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 () => [activeOffer], + fetchPositions: async () => [offer], onPositions: () => {}, onUpdate: update => updates.push(update), onConnectionChange: () => {}, @@ -99,16 +151,86 @@ describe('LivePortfolioEngine', () => { await engine.start(); expect(updates).toHaveLength(0); - // First accrual tick lands at 250ms; the throttle delivers at +1000ms. + // 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(1); + 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(2); + 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 index acefa1033..cd9a7daaa 100644 --- a/invofi/apps/frontend/src/lib/live/engine.ts +++ b/invofi/apps/frontend/src/lib/live/engine.ts @@ -43,14 +43,23 @@ export class LivePortfolioEngine { 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; @@ -61,8 +70,6 @@ export class LivePortfolioEngine { contractIds, rpcUrl, networkPassphrase, - fetchPositions, - onPositions, onUpdate, onConnectionChange, throttleMs = 1_000, @@ -70,28 +77,16 @@ export class LivePortfolioEngine { yieldTickMs = 1_000, } = this.opts; - this.throttle = createPerKeyThrottle(throttleMs, (positionId, update) => { - onUpdate({ ...update, positionId }); + this.throttle = createPerKeyThrottle(throttleMs, (_key, update) => { + onUpdate(update); }); - const resync = async (): Promise => { - if (this.stopped) return; - try { - const offers = await fetchPositions(); - if (this.stopped) return; - this.latestOffers = offers; - onPositions(offers); - } catch { - // A failed resync is non-fatal — the live stream keeps the last state. - } - }; - if (wsUrl) { this.ws = createWebSocketTransport({ url: wsUrl, - onUpdate: update => this.throttle?.(update.positionId, update), + onUpdate: update => this.throttle?.(LivePortfolioEngine.key(update), update), onConnectionChange: (status, detail) => onConnectionChange(status, 'websocket', detail), - onResync: () => void resync(), + onResync: () => void this.resync(), onGiveUp: reason => this.degradeToPolling(reason), }); this.ws.start(); @@ -100,25 +95,30 @@ export class LivePortfolioEngine { } // 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; - this.throttle?.(offer.id, { + 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: yieldEarnedStroops(offer, nowSecs), + 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 resync(), resyncIntervalMs); + this.resyncTimer = setInterval(() => void this.resync(), resyncIntervalMs); - await resync(); + await this.resync(); } /** Switch from the relay to the polling fallback, once. */ @@ -132,32 +132,51 @@ export class LivePortfolioEngine { contractIds: this.opts.contractIds, rpcUrl: this.opts.rpcUrl, networkPassphrase: this.opts.networkPassphrase, - onUpdate: update => this.throttle?.(update.positionId, update), + onUpdate: update => this.throttle?.(LivePortfolioEngine.key(update), update), onConnectionChange: (status, detail) => this.opts.onConnectionChange(status, 'polling', detail), - onResync: () => this.resyncNow(), + onResync: () => void this.resync(), }); this.polling.start(); } - /** Force an immediate full resync (used by refresh buttons + auth changes). */ - resyncNow(): void { - if (this.stopped) return; - const { fetchPositions, onPositions } = this.opts; - fetchPositions() + /** + * 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) return; + if (this.stopped || generation !== this.resyncGeneration) return; this.latestOffers = offers; - onPositions(offers); + this.opts.onPositions(offers); }) .catch(() => { - // Non-fatal — keep the last known state. + // 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. */ + /** + * 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; @@ -170,7 +189,10 @@ export class LivePortfolioEngine { 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/reducer.test.ts b/invofi/apps/frontend/src/lib/live/reducer.test.ts index 869035db0..f6a28f1cd 100644 --- a/invofi/apps/frontend/src/lib/live/reducer.test.ts +++ b/invofi/apps/frontend/src/lib/live/reducer.test.ts @@ -96,6 +96,83 @@ describe('livePortfolioReducer', () => { 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', @@ -135,6 +212,24 @@ describe('livePortfolioReducer', () => { 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', diff --git a/invofi/apps/frontend/src/lib/live/reducer.ts b/invofi/apps/frontend/src/lib/live/reducer.ts index 57d7195b3..b13496a93 100644 --- a/invofi/apps/frontend/src/lib/live/reducer.ts +++ b/invofi/apps/frontend/src/lib/live/reducer.ts @@ -6,7 +6,7 @@ import type { FinancingOffer } from '@invofi/sdk'; import { toStroopsBigInt } from '@/lib/utils'; -import { stroopsFromWire } from './convert'; +import { safeStroopsFromWire } from './convert'; import type { ConnectionStatus, LivePosition, LivePositionUpdate, LiveTransport } from './types'; import { offerApy, remainingStroops, repaymentProgress, totalDueStroops, yieldEarnedStroops } from './yield'; import { usdPriceFor } from './prices'; @@ -79,8 +79,8 @@ function applyUpdate( funded_at: update.fields.funded_at !== undefined ? Number(update.fields.funded_at) : position.funded_at, currency: update.fields.currency ?? position.currency, - amount: stroopsFromWire(update.fields.amount ?? position.amount), - amount_repaid: stroopsFromWire(update.fields.amount_repaid ?? position.amount_repaid), + 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 }; @@ -95,9 +95,18 @@ function applyUpdate( }; 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: position.amount_repaid + update.amountRepaid, + amount_repaid: amountRepaid, }; const derived = deriveLivePosition(merged, now / 1000); return { diff --git a/invofi/apps/frontend/src/lib/live/throttle.test.ts b/invofi/apps/frontend/src/lib/live/throttle.test.ts index f4bb8bc17..ae201b21c 100644 --- a/invofi/apps/frontend/src/lib/live/throttle.test.ts +++ b/invofi/apps/frontend/src/lib/live/throttle.test.ts @@ -6,36 +6,46 @@ describe('createPerKeyThrottle', () => { vi.useRealTimers(); }); - it('coalesces bursts per key and delivers the latest value', () => { + 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); + throttle('a', 1); // leading edge — delivered now throttle('a', 2); throttle('a', 3); - throttle('b', 10); + throttle('b', 10); // leading edge — delivered now + + expect(delivered).toEqual([ + ['a', 1], + ['b', 10], + ]); vi.advanceTimersByTime(1_000); expect(delivered).toEqual([ - ['a', 3], + ['a', 1], ['b', 10], + ['a', 3], ]); }); it('delivers at most once per interval per key', () => { vi.useFakeTimers(); - const delivered: string[] = []; - const throttle = createPerKeyThrottle(1_000, key => delivered.push(key)); + const delivered: Array<[string, number]> = []; + const throttle = createPerKeyThrottle(1_000, (key, value) => delivered.push([key, value])); - for (let i = 0; i < 5; i++) { - throttle('a', String(i)); + 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']); + expect(delivered).toEqual([ + ['a', 0], + ['a', 5], + ]); }); it('keeps per-key windows independent', () => { @@ -46,23 +56,24 @@ describe('createPerKeyThrottle', () => { throttle('a', '1'); vi.advanceTimersByTime(500); throttle('b', '2'); - vi.advanceTimersByTime(500); // a fires now, b is not yet due - throttle('a', '3'); // a is throttled again until the next window - vi.advanceTimersByTime(1_000); // b fires, then a (coalesced) fires + 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 prevents double delivery', () => { + 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); + throttle('a', 1); // leading edge + throttle('a', 2); // pending throttle.flush(); - vi.advanceTimersByTime(1_000); + vi.advanceTimersByTime(2_000); - expect(delivered).toEqual([1]); + expect(delivered).toEqual([1, 2]); }); it('stop cancels timers and drops pending values', () => { @@ -70,10 +81,11 @@ describe('createPerKeyThrottle', () => { const delivered: number[] = []; const throttle = createPerKeyThrottle(1_000, (_key, value) => delivered.push(value)); - throttle('a', 1); + throttle('a', 1); // leading edge already delivered + throttle('a', 2); // pending — dropped by stop() throttle.stop(); vi.advanceTimersByTime(2_000); - expect(delivered).toEqual([]); + 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 index 9d2d47ee8..4159e8cc1 100644 --- a/invofi/apps/frontend/src/lib/live/throttle.ts +++ b/invofi/apps/frontend/src/lib/live/throttle.ts @@ -2,9 +2,9 @@ // 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, so we coalesce by key: while a key has a pending timer, -// newer values replace the pending one, and the *latest* is delivered when the -// timer fires. +// 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. */ @@ -29,22 +29,30 @@ export function createPerKeyThrottle( 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); - if (timers.has(key)) return; // a delivery is already scheduled - timers.set( - key, - setTimeout(() => { - timers.delete(key); - const latest = pending.get(key); - if (latest === undefined) return; - pending.delete(key); - deliver(key, latest); - }, intervalMs), - ); } 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); diff --git a/invofi/apps/frontend/src/lib/live/transports.test.ts b/invofi/apps/frontend/src/lib/live/transports.test.ts index 64a9b5fd3..3742d52ec 100644 --- a/invofi/apps/frontend/src/lib/live/transports.test.ts +++ b/invofi/apps/frontend/src/lib/live/transports.test.ts @@ -47,12 +47,17 @@ class FakeWebSocket { } describe('decodeEnvelope', () => { - it('decodes position_updated', () => { + 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' } }); + ).toEqual({ + kind: 'position_updated', + positionId: 'off_1', + fields: { status: 'Financed' }, + updatedAt: expect.any(Number), + }); }); it('decodes yield_calculated with string bigint amounts', () => { @@ -60,10 +65,16 @@ describe('decodeEnvelope', () => { 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 }); + ).toEqual({ + kind: 'yield_calculated', + positionId: 'off_1', + apy: 12.5, + earnedToDate: 250_000n, + updatedAt: expect.any(Number), + }); }); - it('decodes repayment_received', () => { + it('decodes repayment_received and forwards the cumulative remaining', () => { expect( decodeEnvelope( JSON.stringify({ @@ -79,10 +90,39 @@ describe('decodeEnvelope', () => { 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(); @@ -174,6 +214,82 @@ describe('createWebSocketTransport', () => { 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', () => { @@ -181,6 +297,10 @@ describe('createPollingTransport', () => { listenToEventsMock.mockClear(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('subscribes to protocol events and reports polling status', () => { const statuses: string[] = []; const transport = createPollingTransport({ @@ -199,11 +319,14 @@ describe('createPollingTransport', () => { 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 and requests a resync', () => { + it('maps inv_rep events to repayment updates without an extra resync', () => { type TestEvent = { type: string; subjectId: string; @@ -242,9 +365,61 @@ describe('createPollingTransport', () => { 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: [], diff --git a/invofi/apps/frontend/src/lib/live/transports.ts b/invofi/apps/frontend/src/lib/live/transports.ts index fba944d6c..e8e9b3a9c 100644 --- a/invofi/apps/frontend/src/lib/live/transports.ts +++ b/invofi/apps/frontend/src/lib/live/transports.ts @@ -43,6 +43,10 @@ export interface WebSocketTransportOptions extends TransportCallbacks { 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; } @@ -57,34 +61,53 @@ export function decodeEnvelope(raw: string): LivePositionUpdate | null { } if (!parsed || typeof parsed !== 'object' || typeof parsed.type !== 'string') return null; - switch (parsed.type) { - case 'position_updated': - if (typeof parsed.positionId !== 'string') return null; - return { - kind: 'position_updated', - positionId: parsed.positionId, - fields: parsed.fields ?? {}, - }; - 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), - }; - case 'repayment_received': - if (typeof parsed.positionId !== 'string' || typeof parsed.progress !== 'number') { + // 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; - } - return { - kind: 'repayment_received', - positionId: parsed.positionId, - amountRepaid: stroopsFromWire(parsed.amountRepaid), - fullyRepaid: parsed.fullyRepaid === true, - }; - default: - return null; + } + } catch { + // A malformed amount must never crash the message handler. + return null; } } @@ -108,10 +131,13 @@ export function createWebSocketTransport( 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; @@ -120,6 +146,13 @@ export function createWebSocketTransport( 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'); @@ -133,10 +166,25 @@ export function createWebSocketTransport( } 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; - consecutiveFailures = 0; + // 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(); @@ -154,6 +202,7 @@ export function createWebSocketTransport( ws.onclose = () => { if (stopped || socket !== ws) return; + clearConnectTimer(); socket = null; handleFailure('connection closed'); }; @@ -163,9 +212,14 @@ export function createWebSocketTransport( if (stopped) return; if (everConnected) { - // We were live and dropped — reconnect with backoff. - const backoff = backoffFor(consecutiveFailures); + // 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; @@ -183,6 +237,7 @@ export function createWebSocketTransport( function stop(): void { stopped = true; + clearConnectTimer(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; @@ -244,6 +299,18 @@ export function createPollingTransport( 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; @@ -265,18 +332,26 @@ export function createPollingTransport( fullyRepaid: event.data.fullyRepaid, updatedAt: Date.now(), }); + // Instant feedback for repayments; other mutations need a resync. + return; } - onResync(); + requestResync(); }, - onError() { - // Retries with back-off are handled inside the SDK; the periodic - // Supabase resync keeps the dashboard correct meanwhile. + 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; diff --git a/invofi/apps/frontend/src/lib/live/types.ts b/invofi/apps/frontend/src/lib/live/types.ts index e26a8c31f..86c4c992f 100644 --- a/invofi/apps/frontend/src/lib/live/types.ts +++ b/invofi/apps/frontend/src/lib/live/types.ts @@ -81,6 +81,12 @@ export type LivePositionUpdate = 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; }; diff --git a/invofi/apps/frontend/src/lib/live/yield.test.ts b/invofi/apps/frontend/src/lib/live/yield.test.ts index 8d88e2821..e8eb19e0f 100644 --- a/invofi/apps/frontend/src/lib/live/yield.test.ts +++ b/invofi/apps/frontend/src/lib/live/yield.test.ts @@ -38,6 +38,20 @@ describe('yield / repayment math', () => { 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); diff --git a/invofi/apps/frontend/src/lib/live/yield.ts b/invofi/apps/frontend/src/lib/live/yield.ts index ec25d4ef7..27a62a324 100644 --- a/invofi/apps/frontend/src/lib/live/yield.ts +++ b/invofi/apps/frontend/src/lib/live/yield.ts @@ -63,8 +63,11 @@ export function yieldEarnedStroops( const total = totalYieldStroops(offer); if (total <= 0n) return 0n; const elapsed = Math.max(0, nowSecs - offer.funded_at); - const ratio = Math.min(1, elapsed / offer.duration); - return BigInt(Math.floor(Number(total) * ratio)); + 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). */