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 */}
+ |
+ Metric
+ |
+ {invoices.map((invoice) => (
+
+
+
+
+ {invoice.metadata.debtorName}
+
+
+ {invoice.metadata.invoiceNumber}
+
+
+
+
+ |
+ ))}
+
+
+
+
+ {METRIC_ROWS.map((row, rowIdx) => {
+ const bestIndex = getBestIndex(invoices, row);
+ const Icon = row.icon;
+
+ return (
+
+ {/* Row label */}
+ |
+
+
+ {row.label}
+
+ |
+
+ {/* Invoice values */}
+ {invoices.map((invoice, colIdx) => {
+ const isBest = bestIndex === colIdx;
+ const rawValue = row.getValue(invoice);
+
+ return (
+
+
+
+ {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);