From 5e20f950954ef1504a6d7822bd85e258877254bf Mon Sep 17 00:00:00 2001 From: Aver Date: Sun, 31 May 2026 07:19:21 -0700 Subject: [PATCH] feat: Soroban contract event subscription + invoice comparison (#119, #120) feat(events): implement useContractEvents hook with 10s RPC polling - Add getContractEvents() to lib/stellar/client.ts using rpc.getEvents API - Parse event XDR to extract tokenId, amount, participantAddress - Track last processed ledger as cursor to avoid reprocessing - Invalidate TanStack Query caches per event type (funded/repaid/yield) - Show wallet-relevant toasts via sonner (respects notificationPreferences) - Mock event generator for NEXT_PUBLIC_ENABLE_MOCK_DATA=true mode - Mount via ContractEventSubscriber in marketplace + investor dashboard layouts feat(comparison): invoice side-by-side comparison feature - Add comparisonList: string[] to invoiceStore with toggleComparison/remove/clear - Add compare toggle button to InvoiceCard (max 3, disabled when full) - Build ComparisonBar fixed bottom component with invoice chips + share URL - Build ComparisonTable modal with 10 metric rows and best-value green highlight - Shareable URLs via ?compare=id1,id2,id3 query param Closes #119 Closes #120 --- app/dashboard/investor/layout.tsx | 16 +- app/marketplace/layout.tsx | 16 +- app/marketplace/page.tsx | 3 + components/invoice/InvoiceCard.tsx | 38 +- components/marketplace/ComparisonBar.tsx | 192 ++++++++++ components/marketplace/ComparisonTable.tsx | 341 ++++++++++++++++++ .../marketplace/ContractEventSubscriber.tsx | 17 + hooks/useContractEvents.ts | 295 +++++++++++++++ lib/stellar/client.ts | 226 ++++++++++++ store/invoiceStore.ts | 26 ++ 10 files changed, 1166 insertions(+), 4 deletions(-) create mode 100644 components/marketplace/ComparisonBar.tsx create mode 100644 components/marketplace/ComparisonTable.tsx create mode 100644 components/marketplace/ContractEventSubscriber.tsx create mode 100644 hooks/useContractEvents.ts diff --git a/app/dashboard/investor/layout.tsx b/app/dashboard/investor/layout.tsx index 07f7b06..c792008 100644 --- a/app/dashboard/investor/layout.tsx +++ b/app/dashboard/investor/layout.tsx @@ -1,4 +1,13 @@ import type { Metadata } from "next"; +import dynamic from "next/dynamic"; + +const ContractEventSubscriber = dynamic( + () => + import("@/components/marketplace/ContractEventSubscriber").then( + (m) => m.ContractEventSubscriber + ), + { ssr: false, loading: () => null } +); export const metadata: Metadata = { title: "Investor Dashboard", @@ -29,5 +38,10 @@ export const metadata: Metadata = { import { ConnectWalletGuard } from "@/components/layout/ConnectWalletGuard"; export default function InvestorDashboardLayout({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + + {children} + + ); } diff --git a/app/marketplace/layout.tsx b/app/marketplace/layout.tsx index 2648779..6c6597f 100644 --- a/app/marketplace/layout.tsx +++ b/app/marketplace/layout.tsx @@ -1,4 +1,13 @@ import type { Metadata } from "next"; +import dynamic from "next/dynamic"; + +const ContractEventSubscriber = dynamic( + () => + import("@/components/marketplace/ContractEventSubscriber").then( + (m) => m.ContractEventSubscriber + ), + { ssr: false, loading: () => null } +); export const metadata: Metadata = { title: "Invoice Marketplace", @@ -27,5 +36,10 @@ export const metadata: Metadata = { }; export default function MarketplaceLayout({ children }: { children: React.ReactNode }) { - return <>{children}; + return ( + <> + + {children} + + ); } diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index cf40e30..89bb53a 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -32,6 +32,7 @@ import { cn } from "@/lib/utils"; import { sanitizeQueryParam } from "@/lib/security"; import { ErrorBoundary } from "@/components/ui/error-boundary"; import { RangeSlider } from "@/components/ui/range-slider"; +import { ComparisonBar } from "@/components/marketplace/ComparisonBar"; // ─── Filter Options ────────────────────────────────────────────────────────── @@ -767,6 +768,8 @@ function MarketplaceContent() { )} + {/* Fixed comparison bar — renders above the page when invoices are selected */} + ); } diff --git a/components/invoice/InvoiceCard.tsx b/components/invoice/InvoiceCard.tsx index c444dc1..119dfb9 100644 --- a/components/invoice/InvoiceCard.tsx +++ b/components/invoice/InvoiceCard.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { motion } from "framer-motion"; -import { Calendar, Users, TrendingUp, MapPin, ArrowRight, Clock } from "lucide-react"; +import { Calendar, Users, TrendingUp, MapPin, ArrowRight, Clock, GitCompareArrows } from "lucide-react"; import { RiskBadge, Badge } from "@/components/ui/badge"; import { InvoiceFundingProgress } from "@/components/ui/progress"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,7 @@ import useCountdown from "@/hooks/useCountdown"; import CountdownTimer from "@/components/ui/CountdownTimer"; import { InvoiceStatusBadge } from "./InvoiceStatusBadge"; import { DebtorDisplay } from "./DebtorDisplay"; +import { useInvoiceStore } from "@/store/invoiceStore"; import type { Invoice } from "@/types"; interface InvoiceCardProps { @@ -70,6 +71,9 @@ export function InvoiceCard({ invoice, index = 0, updatedAt }: InvoiceCardProps) const flag = getFlagEmoji(metadata.jurisdiction); const countryName = JURISDICTION_NAMES[metadata.jurisdiction] || metadata.jurisdiction; const queryClient = useQueryClient(); + const { comparisonList, toggleComparison } = useInvoiceStore(); + const isInComparison = comparisonList.includes(invoice.id); + const comparisonFull = comparisonList.length >= 3 && !isInComparison; // Check if invoice is expired const countdown = useCountdown(listingExpiry); @@ -83,6 +87,12 @@ export function InvoiceCard({ invoice, index = 0, updatedAt }: InvoiceCardProps) }); }; + const handleCompareToggle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!comparisonFull) toggleComparison(invoice.id); + }; + return ( ) : null} - + + {/* Compare toggle button */} + {/* Hover overlay CTA */}
diff --git a/components/marketplace/ComparisonBar.tsx b/components/marketplace/ComparisonBar.tsx new file mode 100644 index 0000000..8a0d46a --- /dev/null +++ b/components/marketplace/ComparisonBar.tsx @@ -0,0 +1,192 @@ +"use client"; + +/** + * ComparisonBar — fixed bottom bar showing invoices selected for comparison. + * + * Appears when 1+ invoices are in the comparison list. Shows invoice chips + * with remove buttons and a "Compare" CTA that opens the ComparisonTable. + * Supports shareable URLs with comparison invoice IDs. + */ + +import { useState, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { X, GitCompareArrows, Share2, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useInvoiceStore } from "@/store/invoiceStore"; +import { cn } from "@/lib/utils"; +import { ComparisonTable } from "./ComparisonTable"; + +const MAX_COMPARISON = 3; + +export function ComparisonBar() { + const { comparisonList, invoices, removeFromComparison, clearComparison } = + useInvoiceStore(); + const [tableOpen, setTableOpen] = useState(false); + const [copied, setCopied] = useState(false); + const router = useRouter(); + + const selectedInvoices = comparisonList + .map((id) => invoices.find((inv) => inv.id === id)) + .filter(Boolean) as NonNullable>[]; + + // Sync comparison list from URL on mount (shareable links) + const searchParams = useSearchParams(); + useEffect(() => { + const compareParam = searchParams.get("compare"); + if (compareParam) { + const ids = compareParam.split(",").filter(Boolean).slice(0, MAX_COMPARISON); + const { toggleComparison, comparisonList: current } = useInvoiceStore.getState(); + ids.forEach((id) => { + if (!current.includes(id)) toggleComparison(id); + }); + // Auto-open the table when arriving via a share link + if (ids.length >= 2) setTableOpen(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleShare = async () => { + const url = new URL(window.location.href); + url.searchParams.set("compare", comparisonList.join(",")); + try { + await navigator.clipboard.writeText(url.toString()); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback: update URL without clipboard + router.replace(`${url.pathname}?${url.searchParams.toString()}`, { + scroll: false, + }); + } + }; + + if (comparisonList.length === 0) return null; + + return ( + <> + {/* Comparison Table Modal */} + + {tableOpen && ( + setTableOpen(false)} + /> + )} + + + {/* Fixed Bottom Bar */} + +
+ {/* Label */} +
+ + + Compare + + + ({comparisonList.length}/{MAX_COMPARISON}) + +
+ + {/* Invoice chips */} +
+ {selectedInvoices.map((invoice) => ( + + + {invoice.metadata.debtorName} + + + {invoice.terms.apr.toFixed(1)}% + + + + ))} + + {/* Empty slots */} + {Array.from({ length: MAX_COMPARISON - comparisonList.length }).map( + (_, i) => ( +
+ + Add +
+ ) + )} +
+ + {/* Actions */} +
+ + + + + +
+
+
+ + ); +} diff --git a/components/marketplace/ComparisonTable.tsx b/components/marketplace/ComparisonTable.tsx new file mode 100644 index 0000000..df8650a --- /dev/null +++ b/components/marketplace/ComparisonTable.tsx @@ -0,0 +1,341 @@ +"use client"; + +/** + * ComparisonTable — side-by-side invoice comparison modal. + * + * Displays up to 3 invoices in columns with rows for each key metric. + * The best value in each row is highlighted in green. + * Each column has an X button to remove that invoice from the comparison. + */ + +import { motion } from "framer-motion"; +import { X, TrendingUp, Calendar, Shield, MapPin, Users, DollarSign, Clock, Activity } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { RiskBadge } from "@/components/ui/badge"; +import { InvoiceFundingProgress } from "@/components/ui/progress"; +import { InvoiceStatusBadge } from "@/components/invoice/InvoiceStatusBadge"; +import { useInvoiceStore } from "@/store/invoiceStore"; +import { + formatCurrency, + formatApr, + formatDate, + cn, +} from "@/lib/utils"; +import type { Invoice } from "@/types"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ComparisonTableProps { + invoices: Invoice[]; + onClose: () => void; +} + +// ─── Row definitions ────────────────────────────────────────────────────────── + +type BestValueDirection = "higher" | "lower" | "none"; + +interface MetricRow { + label: string; + icon: React.ElementType; + getValue: (inv: Invoice) => number | string; + /** Numeric value used for best-value comparison */ + getNumericValue?: (inv: Invoice) => number; + /** Whether higher or lower is better */ + bestDirection: BestValueDirection; + format: (val: number | string, inv: Invoice) => React.ReactNode; +} + +const METRIC_ROWS: MetricRow[] = [ + { + label: "Invoice Amount", + icon: DollarSign, + getValue: (inv) => inv.metadata.amount, + getNumericValue: (inv) => inv.metadata.amount, + bestDirection: "higher", + format: (val, inv) => formatCurrency(val as number, inv.metadata.currency, true), + }, + { + label: "APR", + icon: TrendingUp, + getValue: (inv) => inv.terms.apr, + getNumericValue: (inv) => inv.terms.apr, + bestDirection: "higher", + format: (val) => ( + {formatApr(val as number)} + ), + }, + { + label: "Risk Tier", + icon: Shield, + getValue: (inv) => inv.riskTier, + getNumericValue: (inv) => { + // Lower tier index = lower risk = better + const order = ["AAA", "AA", "A", "BBB", "BB", "B", "CCC"]; + return order.indexOf(inv.riskTier); + }, + bestDirection: "lower", + format: (val, inv) => , + }, + { + label: "Jurisdiction", + icon: MapPin, + getValue: (inv) => inv.metadata.jurisdiction, + bestDirection: "none", + format: (val) => String(val), + }, + { + label: "Due Date", + icon: Calendar, + getValue: (inv) => inv.terms.repaymentDate, + getNumericValue: (inv) => new Date(inv.terms.repaymentDate).getTime(), + bestDirection: "higher", // further out = more time = better for investor + format: (val) => formatDate(val as string), + }, + { + label: "Tenor", + icon: Clock, + getValue: (inv) => inv.terms.tenor, + getNumericValue: (inv) => inv.terms.tenor, + bestDirection: "none", + format: (val) => `${val} days`, + }, + { + label: "Funding Progress", + icon: Activity, + getValue: (inv) => inv.funding.fundingProgress, + getNumericValue: (inv) => inv.funding.fundingProgress, + bestDirection: "none", + format: (val, inv) => ( + + ), + }, + { + label: "Investors", + icon: Users, + getValue: (inv) => inv.funding.investorCount, + getNumericValue: (inv) => inv.funding.investorCount, + bestDirection: "higher", + format: (val) => String(val), + }, + { + label: "Min Investment", + icon: DollarSign, + getValue: (inv) => inv.terms.minInvestment, + getNumericValue: (inv) => inv.terms.minInvestment, + bestDirection: "lower", + format: (val, inv) => formatCurrency(val as number, inv.metadata.currency, true), + }, + { + label: "Status", + icon: Activity, + getValue: (inv) => inv.status, + bestDirection: "none", + format: (val, inv) => , + }, +]; + +// ─── Best-value detection ───────────────────────────────────────────────────── + +function getBestIndex( + invoices: Invoice[], + row: MetricRow +): number | null { + if (row.bestDirection === "none" || !row.getNumericValue) return null; + if (invoices.length < 2) return null; + + const values = invoices.map(row.getNumericValue); + const best = + row.bestDirection === "higher" + ? Math.max(...values) + : Math.min(...values); + + // Only highlight if there's a clear winner (not all equal) + const allEqual = values.every((v) => v === values[0]); + if (allEqual) return null; + + return values.indexOf(best); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ComparisonTable({ invoices, onClose }: ComparisonTableProps) { + const { removeFromComparison } = useInvoiceStore(); + + return ( + + {/* Backdrop */} + + + {/* Panel */} + + {/* Header */} +
+
+ +

+ Invoice Comparison +

+ + {invoices.length} selected + +
+ +
+ + {/* Scrollable table */} +
+ + {/* Column headers — one per invoice */} + + + {/* Row label column */} + + {invoices.map((invoice) => ( + + ))} + + + + + {METRIC_ROWS.map((row, rowIdx) => { + const bestIndex = getBestIndex(invoices, row); + const Icon = row.icon; + + return ( + + {/* Row label */} + + + {/* Invoice values */} + {invoices.map((invoice, colIdx) => { + const isBest = bestIndex === colIdx; + const rawValue = row.getValue(invoice); + + return ( + + ); + })} + + ); + })} + +
+ Metric + +
+
+

+ {invoice.metadata.debtorName} +

+

+ {invoice.metadata.invoiceNumber} +

+
+ +
+
+
+ + {row.label} +
+
+
+ + {row.format(rawValue, invoice)} + + {isBest && ( + + Best + + )} +
+
+
+ + {/* Footer — CTA links */} +
+ +
+ {invoices.map((invoice) => ( + + + + ))} +
+
+
+
+ ); +} diff --git a/components/marketplace/ContractEventSubscriber.tsx b/components/marketplace/ContractEventSubscriber.tsx new file mode 100644 index 0000000..d9072d6 --- /dev/null +++ b/components/marketplace/ContractEventSubscriber.tsx @@ -0,0 +1,17 @@ +"use client"; + +/** + * ContractEventSubscriber — mounts the Soroban event polling hook. + * + * This is a render-nothing component that activates the useContractEvents + * hook for the marketplace and dashboard pages. It is intentionally kept + * separate from the layout so it can be dynamically imported (ssr: false) + * without affecting server-rendered metadata. + */ + +import { useContractEvents } from "@/hooks/useContractEvents"; + +export function ContractEventSubscriber() { + useContractEvents(); + return null; +} diff --git a/hooks/useContractEvents.ts b/hooks/useContractEvents.ts new file mode 100644 index 0000000..0779e21 --- /dev/null +++ b/hooks/useContractEvents.ts @@ -0,0 +1,295 @@ +"use client"; + +/** + * useContractEvents — polls the Soroban RPC getEvents API every 10 seconds + * for invoice_funded, invoice_repaid, and yield_distributed events. + * + * On each poll: + * - Parses event XDR to extract tokenId, amount, and participant address + * - Invalidates relevant TanStack Query caches + * - Shows a toast for events involving the connected wallet address + * - Tracks the last processed ledger to avoid reprocessing events + */ + +import { useEffect, useRef, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + getContractEvents, + type ContractEvent, + type KoraEventType, +} from "@/lib/stellar/client"; +import { queryKeys } from "@/lib/queryKeys"; +import { useWalletStore } from "@/store/walletStore"; +import { useUIStore } from "@/store/uiStore"; +import { env } from "@/lib/env"; +import { formatCurrency, truncateAddress } from "@/lib/utils"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const POLL_INTERVAL_MS = 10_000; + +const EVENT_TYPES: KoraEventType[] = [ + "invoice_funded", + "invoice_repaid", + "yield_distributed", +]; + +// ─── Toast helpers ──────────────────────────────────────────────────────────── + +function showEventToast(event: ContractEvent, walletAddress: string) { + const isRelevant = + event.participantAddress.toLowerCase() === walletAddress.toLowerCase(); + + if (!isRelevant) return; + + const amountStr = formatCurrency(event.amount, "USDC"); + const shortAddr = truncateAddress(event.participantAddress, 4); + + switch (event.type) { + case "invoice_funded": + toast.success( +
+ Invoice Funded + + {amountStr} invested · Invoice #{event.tokenId} + +
, + { duration: 5000 } + ); + break; + + case "invoice_repaid": + toast.success( +
+ Invoice Repaid + + Invoice #{event.tokenId} has been fully repaid + +
, + { duration: 5000 } + ); + break; + + case "yield_distributed": + toast.success( +
+ Yield Distributed 🎉 + + {amountStr} yield sent to {shortAddr} + +
, + { duration: 7000 } + ); + break; + } +} + +// ─── Cache invalidation ─────────────────────────────────────────────────────── + +/** + * Invalidate TanStack Query caches based on the event type. + * Maps each event to the relevant query keys that need refreshing. + */ +function invalidateCachesForEvent( + event: ContractEvent, + queryClient: ReturnType +) { + switch (event.type) { + case "invoice_funded": + // Refresh the specific invoice detail and the full list (funding progress changed) + queryClient.invalidateQueries({ + queryKey: queryKeys.invoices.detail(event.tokenId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.invoices.all, + }); + break; + + case "invoice_repaid": + // Refresh invoice detail (status → repaid) and investor positions + queryClient.invalidateQueries({ + queryKey: queryKeys.invoices.detail(event.tokenId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.invoices.all, + }); + // Invalidate all positions (yield may now be claimable) + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && + query.queryKey[0] === "invoices" && + query.queryKey[1] === "positions", + }); + break; + + case "yield_distributed": + // Refresh investor positions and account balances (USDC balance changed) + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && + query.queryKey[0] === "invoices" && + query.queryKey[1] === "positions", + }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === "account", + }); + break; + } +} + +// ─── Mock event generator (for development with mock data) ──────────────────── + +let _mockLedger = 1000; + +function generateMockEvents( + walletAddress: string, + startLedger: number +): { events: ContractEvent[]; latestLedger: number } { + _mockLedger += 1; + + // Only emit a mock event occasionally (every ~30s = every 3rd poll) + if (_mockLedger % 3 !== 0) { + return { events: [], latestLedger: _mockLedger }; + } + + const eventTypes: KoraEventType[] = [ + "invoice_funded", + "invoice_repaid", + "yield_distributed", + ]; + const type = eventTypes[_mockLedger % eventTypes.length]; + + const event: ContractEvent = { + id: `mock-event-${_mockLedger}`, + ledger: _mockLedger, + ledgerClosedAt: new Date().toISOString(), + contractId: env.NEXT_PUBLIC_MARKETPLACE_CONTRACT_ID, + type, + tokenId: String((_mockLedger % 5) + 1), + amount: type === "yield_distributed" ? 125.5 : 5000, + participantAddress: walletAddress, + rawTopics: [type], + }; + + return { events: [event], latestLedger: _mockLedger }; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export interface UseContractEventsOptions { + /** Override the contract ID to listen on (defaults to MARKETPLACE_CONTRACT_ID) */ + contractId?: string; + /** Override the poll interval in ms (defaults to 10_000) */ + pollIntervalMs?: number; + /** Disable polling entirely */ + disabled?: boolean; +} + +/** + * Subscribes to Soroban contract events via polling. + * + * Polls every 10 seconds for invoice_funded, invoice_repaid, and + * yield_distributed events. Invalidates TanStack Query caches and shows + * wallet-relevant toasts on each new event. + * + * Uses a ledger cursor to avoid reprocessing events across polls. + */ +export function useContractEvents(options: UseContractEventsOptions = {}) { + const { + contractId = env.NEXT_PUBLIC_MARKETPLACE_CONTRACT_ID, + pollIntervalMs = POLL_INTERVAL_MS, + disabled = false, + } = options; + + const queryClient = useQueryClient(); + const { address: walletAddress } = useWalletStore(); + const notificationPreferences = useUIStore((s) => s.notificationPreferences); + + // Track the last processed ledger to use as cursor + const lastLedgerRef = useRef(0); + // Track processed event IDs to deduplicate within a session + const processedEventIds = useRef>(new Set()); + const intervalRef = useRef | null>(null); + + const poll = useCallback(async () => { + // Skip polling when tab is hidden to save resources + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + return; + } + + try { + let result: { events: ContractEvent[]; latestLedger: number }; + + if (env.NEXT_PUBLIC_ENABLE_MOCK_DATA) { + result = generateMockEvents(walletAddress ?? "", lastLedgerRef.current); + } else { + result = await getContractEvents({ + contractId, + eventTypes: EVENT_TYPES, + startLedger: lastLedgerRef.current, + }); + } + + const { events, latestLedger } = result; + + // Update cursor to the latest ledger seen + if (latestLedger > lastLedgerRef.current) { + lastLedgerRef.current = latestLedger; + } + + // Process only new, unseen events + const newEvents = events.filter( + (e) => !processedEventIds.current.has(e.id) + ); + + for (const event of newEvents) { + processedEventIds.current.add(event.id); + + // 1. Invalidate relevant TanStack Query caches + invalidateCachesForEvent(event, queryClient); + + // 2. Show toast for wallet-relevant events (if notifications enabled) + if (walletAddress && notificationPreferences.invoiceFunded) { + showEventToast(event, walletAddress); + } + } + + // Prevent the processed set from growing unboundedly + if (processedEventIds.current.size > 500) { + const arr = Array.from(processedEventIds.current); + processedEventIds.current = new Set(arr.slice(-250)); + } + } catch (err) { + // Silently swallow polling errors — the UI should not break if events + // are temporarily unavailable. Log for debugging only. + if (process.env.NODE_ENV === "development") { + console.warn("[useContractEvents] Poll error:", err); + } + } + }, [contractId, queryClient, walletAddress, notificationPreferences.invoiceFunded]); + + useEffect(() => { + if (disabled) return; + + // Run an initial poll immediately + poll(); + + intervalRef.current = setInterval(poll, pollIntervalMs); + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + // Immediately poll when tab becomes visible again + poll(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [disabled, poll, pollIntervalMs]); +} diff --git a/lib/stellar/client.ts b/lib/stellar/client.ts index 2c531a8..8bfd133 100644 --- a/lib/stellar/client.ts +++ b/lib/stellar/client.ts @@ -240,6 +240,232 @@ export async function getAccountTransactions( }; } +// ─── Contract Events ────────────────────────────────────────────────────────── + +/** + * Supported Kora contract event types emitted by the Soroban contracts. + */ +export type KoraEventType = + | "invoice_funded" + | "invoice_repaid" + | "yield_distributed"; + +/** + * Parsed representation of a single Soroban contract event. + */ +export interface ContractEvent { + /** Unique event identifier (ledger + tx index) */ + id: string; + /** Ledger sequence number this event was emitted in */ + ledger: number; + /** ISO timestamp of the ledger close */ + ledgerClosedAt: string; + /** Contract that emitted the event */ + contractId: string; + /** Discriminated event type */ + type: KoraEventType; + /** On-chain token/invoice ID */ + tokenId: string; + /** Amount involved (in human-readable units, divided by 1_000_000) */ + amount: number; + /** Participant address (investor for funded/yield, SME for repaid) */ + participantAddress: string; + /** Raw topic ScVals for debugging */ + rawTopics: string[]; +} + +/** + * Parameters for `getContractEvents`. + */ +export interface GetContractEventsParams { + contractId: string; + eventTypes: KoraEventType[]; + /** Ledger sequence to start from (exclusive). Pass 0 to start from the latest. */ + startLedger: number; +} + +/** + * Result from `getContractEvents`. + */ +export interface GetContractEventsResult { + events: ContractEvent[]; + /** The highest ledger sequence seen — use as `startLedger` for the next poll */ + latestLedger: number; +} + +/** + * Parse a raw Soroban event topic ScVal to a string. + * Topics are typically Symbol or String ScVals. + */ +function parseTopicToString(val: StellarSdk.xdr.ScVal): string { + try { + if (val.switch().name === "scvSymbol") return val.sym().toString(); + if (val.switch().name === "scvString") return val.str().toString(); + if (val.switch().name === "scvAddress") { + return StellarSdk.Address.fromScVal(val).toString(); + } + if (val.switch().name === "scvU64") return val.u64().toString(); + if (val.switch().name === "scvI128") { + return val.i128().lo().toString(); + } + return val.toXDR("base64"); + } catch { + return ""; + } +} + +/** + * Parse a raw Soroban event data ScVal to extract amount (i128) as a number. + */ +function parseAmountFromData(val: StellarSdk.xdr.ScVal): number { + try { + if (val.switch().name === "scvI128") { + return Number(val.i128().lo().toString()) / 1_000_000; + } + if (val.switch().name === "scvU64") { + return Number(val.u64().toString()) / 1_000_000; + } + // Map: look for "amount" key + if (val.switch().name === "scvMap") { + const map = val.map(); + if (map) { + const entry = map.find((e) => { + try { return e.key().sym().toString() === "amount"; } catch { return false; } + }); + if (entry) return parseAmountFromData(entry.val()); + } + } + return 0; + } catch { + return 0; + } +} + +/** + * Parse a raw Soroban event data ScVal to extract a participant address. + */ +function parseAddressFromData(val: StellarSdk.xdr.ScVal): string { + try { + if (val.switch().name === "scvAddress") { + return StellarSdk.Address.fromScVal(val).toString(); + } + if (val.switch().name === "scvMap") { + const map = val.map(); + if (map) { + // Try common field names + for (const key of ["investor", "owner", "participant", "from", "to"]) { + const entry = map.find((e) => { + try { return e.key().sym().toString() === key; } catch { return false; } + }); + if (entry) { + try { + return StellarSdk.Address.fromScVal(entry.val()).toString(); + } catch { /* continue */ } + } + } + } + } + return ""; + } catch { + return ""; + } +} + +/** + * Parse a raw Soroban event data ScVal to extract a token/invoice ID. + */ +function parseTokenIdFromData(val: StellarSdk.xdr.ScVal): string { + try { + if (val.switch().name === "scvU64") return val.u64().toString(); + if (val.switch().name === "scvMap") { + const map = val.map(); + if (map) { + for (const key of ["token_id", "invoice_id", "id"]) { + const entry = map.find((e) => { + try { return e.key().sym().toString() === key; } catch { return false; } + }); + if (entry) { + try { return entry.val().u64().toString(); } catch { /* continue */ } + } + } + } + } + return ""; + } catch { + return ""; + } +} + +/** + * Fetch Soroban contract events from the RPC node. + * + * Polls `rpc.getEvents` for the given contract and event types starting from + * `startLedger`. Returns parsed `ContractEvent[]` and the latest ledger seen. + * + * @param params - Contract ID, event types to filter, and cursor ledger + */ +export async function getContractEvents( + params: GetContractEventsParams +): Promise { + const { contractId, eventTypes, startLedger } = params; + + // Build topic filters — each event type is a Symbol in topic[0] + const filters: StellarSdk.rpc.Api.EventFilter[] = eventTypes.map((eventType) => ({ + type: "contract" as const, + contractIds: [contractId], + topics: [ + [StellarSdk.xdr.ScVal.scvSymbol(eventType).toXDR("base64")], + ], + })); + + const response = await rpc.getEvents({ + startLedger: startLedger > 0 ? startLedger : undefined, + filters, + limit: 100, + }); + + const latestLedger = response.latestLedger ?? startLedger; + + const events: ContractEvent[] = response.events + .map((raw): ContractEvent | null => { + try { + // Decode topics from base64 XDR + const topics = raw.topic.map((t) => { + const val = StellarSdk.xdr.ScVal.fromXDR(t, "base64"); + return parseTopicToString(val); + }); + + // topic[0] is the event name (Symbol) + const eventName = topics[0] as KoraEventType; + if (!eventTypes.includes(eventName)) return null; + + // Decode data payload + const dataVal = StellarSdk.xdr.ScVal.fromXDR(raw.value, "base64"); + + const tokenId = parseTokenIdFromData(dataVal) || topics[1] || ""; + const amount = parseAmountFromData(dataVal); + const participantAddress = parseAddressFromData(dataVal) || topics[2] || ""; + + return { + id: raw.id, + ledger: raw.ledger, + ledgerClosedAt: raw.ledgerClosedAt, + contractId: raw.contractId, + type: eventName, + tokenId, + amount, + participantAddress, + rawTopics: topics, + }; + } catch { + return null; + } + }) + .filter((e): e is ContractEvent => e !== null); + + return { events, latestLedger }; +} + // ─── Transaction submission ─────────────────────────────────────────────────── /** diff --git a/store/invoiceStore.ts b/store/invoiceStore.ts index 926189a..7c452a5 100644 --- a/store/invoiceStore.ts +++ b/store/invoiceStore.ts @@ -174,6 +174,9 @@ interface InvoiceStore { selectedInvoice: Invoice | null; createDraft: InvoiceCreateDraft; + /** IDs of invoices selected for side-by-side comparison (max 3) */ + comparisonList: string[]; + // Actions setInvoices: (invoices: Invoice[]) => void; setFilters: (filters: Partial) => void; @@ -191,6 +194,13 @@ interface InvoiceStore { setCreateDraft: (draft: Partial) => void; clearCreateDraft: () => void; + /** Toggle an invoice in/out of the comparison list (max 3) */ + toggleComparison: (id: string) => void; + /** Remove a single invoice from the comparison list */ + removeFromComparison: (id: string) => void; + /** Clear the entire comparison list */ + clearComparison: () => void; + // Derived getFiltered: () => Invoice[]; } @@ -206,6 +216,7 @@ export const useInvoiceStore = create()( searchHistory: loadSearchHistory(), selectedInvoice: null, createDraft: { currency: "USDC" }, + comparisonList: [], setInvoices: (invoices) => set({ invoices }), @@ -296,6 +307,21 @@ export const useInvoiceStore = create()( clearCreateDraft: () => set({ createDraft: { currency: "USDC" } }), + toggleComparison: (id) => + set((s) => { + const list = s.comparisonList; + if (list.includes(id)) { + return { comparisonList: list.filter((i) => i !== id) }; + } + if (list.length >= 3) return {}; // max 3 + return { comparisonList: [...list, id] }; + }), + + removeFromComparison: (id) => + set((s) => ({ comparisonList: s.comparisonList.filter((i) => i !== id) })), + + clearComparison: () => set({ comparisonList: [] }), + getFiltered: () => { const { invoices, filters, sort, searchQuery } = get(); return getFilteredInvoices(invoices, filters, sort, searchQuery);