From 72efd09e51efc68f59916b4e601ab29329694e2a Mon Sep 17 00:00:00 2001 From: Emmanuelluxury Date: Thu, 22 Jan 2026 13:24:27 +0100 Subject: [PATCH 1/7] feat: Add Recent Contract Events section - Add fetchEvents function using Stellar RPC getEvents - Create EventTable component for displaying contract events - Add Events tab to transaction history view - Handle empty/error states gracefully - Events limited to ~7 days RPC retention - Network-aware (testnet/mainnet) - No persistence or ingestion as required --- package-lock.json | 16 +- src/components/escrow/EscrowDetails.tsx | 105 ++++++++-- src/components/escrow/EventTable.tsx | 264 ++++++++++++++++++++++++ src/utils/transactionFetcher.ts | 95 ++++++++- 4 files changed, 452 insertions(+), 28 deletions(-) create mode 100644 src/components/escrow/EventTable.tsx diff --git a/package-lock.json b/package-lock.json index ff4af88..d3097d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "clsx": "^2.1.1", "framer-motion": "^12.5.0", "lucide-react": "^0.482.0", - "next": "15.3.6", + "next": "15.3.8", "next-themes": "^0.4.6", "react": "^19.1.2", "react-dom": "^19.1.2", @@ -808,9 +808,9 @@ } }, "node_modules/@next/env": { - "version": "15.3.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.6.tgz", - "integrity": "sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw==", + "version": "15.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.8.tgz", + "integrity": "sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -6802,12 +6802,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.3.6", - "resolved": "https://registry.npmjs.org/next/-/next-15.3.6.tgz", - "integrity": "sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w==", + "version": "15.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-15.3.8.tgz", + "integrity": "sha512-L+4c5Hlr84fuaNADZbB9+ceRX9/CzwxJ+obXIGHupboB/Q1OLbSUapFs4bO8hnS/E6zV/JDX7sG1QpKVR2bguA==", "license": "MIT", "dependencies": { - "@next/env": "15.3.6", + "@next/env": "15.3.8", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", diff --git a/src/components/escrow/EscrowDetails.tsx b/src/components/escrow/EscrowDetails.tsx index 776fe90..4991dab 100644 --- a/src/components/escrow/EscrowDetails.tsx +++ b/src/components/escrow/EscrowDetails.tsx @@ -6,8 +6,10 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { motion } from "framer-motion"; import { NavbarSimple } from "@/components/Navbar"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { LoadingLogo } from "@/components/shared/loading-logo"; import { EXAMPLE_CONTRACT_IDS } from "@/lib/escrow-constants"; +import { getNetworkConfig } from "@/lib/network-config"; import { useRouter } from "next/navigation"; import { useNetwork } from "@/contexts/NetworkContext"; @@ -16,11 +18,15 @@ import { SearchCard } from "@/components/escrow/search-card"; import { ErrorDisplay } from "@/components/escrow/error-display"; import { EscrowContent } from "@/components/escrow/escrow-content"; import { TransactionTable } from "@/components/escrow/TransactionTable"; +import { EventTable } from "@/components/escrow/EventTable"; import { TransactionDetailModal } from "@/components/escrow/TransactionDetailModal"; import { fetchTransactions, + fetchEvents, type TransactionMetadata, type TransactionResponse, + type EventMetadata, + type EventResponse, } from "@/utils/transactionFetcher"; import { LedgerBalancePanel } from "@/components/escrow/LedgerBalancePanel"; import { useIsMobile } from "@/hooks/useIsMobile"; @@ -86,6 +92,13 @@ const isMobile = useIsMobile(); const [showOnlyTransactions, setShowOnlyTransactions] = useState(false); const txRef = useRef(null); + // Event-related state + const [events, setEvents] = useState([]); + const [eventLoading, setEventLoading] = useState(false); + const [eventError, setEventError] = useState(null); + const [eventResponse, setEventResponse] = useState(null); + const [activeTab, setActiveTab] = useState<'transactions' | 'events'>('transactions'); + // Fetch transaction data const fetchTransactionData = useCallback( @@ -110,12 +123,37 @@ const isMobile = useIsMobile(); [] ); - // Initial + network-change fetch (escrow + txs) + // Fetch event data + const fetchEventData = useCallback( + async (id: string, rpcUrl: string, cursor?: string) => { + if (!id) return; + setEventLoading(true); + setEventError(null); + try { + const response = await fetchEvents(id, rpcUrl, cursor, 20); + setEventResponse(response); + if (cursor) { + setEvents((prev) => [...prev, ...response.events]); + } else { + setEvents(response.events); + } + } catch (err: any) { + setEventError(err.message || "Failed to fetch events"); + } finally { + setEventLoading(false); + } + }, + [] + ); + + // Initial + network-change fetch (escrow + txs + events) useEffect(() => { if (!contractId) return; - // useEscrowData auto-refreshes on contractId change; just ensure txs loaded: + // useEscrowData auto-refreshes on contractId change; just ensure txs and events loaded: fetchTransactionData(contractId); - }, [contractId, currentNetwork, fetchTransactionData]); + const { rpcUrl } = getNetworkConfig(currentNetwork); + fetchEventData(contractId, rpcUrl); + }, [contractId, currentNetwork, fetchTransactionData, fetchEventData]); // Enter key in search const handleKeyDown = (e: React.KeyboardEvent) => { @@ -126,6 +164,8 @@ const isMobile = useIsMobile(); } void refresh(); fetchTransactionData(contractId); + const { rpcUrl } = getNetworkConfig(currentNetwork); + fetchEventData(contractId, rpcUrl); } }; @@ -145,6 +185,8 @@ const isMobile = useIsMobile(); } await refresh(); fetchTransactionData(contractId); + const { rpcUrl } = getNetworkConfig(currentNetwork); + fetchEventData(contractId, rpcUrl); }; // Transactions UI handlers @@ -162,6 +204,13 @@ const isMobile = useIsMobile(); } }; + const handleLoadMoreEvents = () => { + if (eventResponse?.cursor && contractId) { + const { rpcUrl } = getNetworkConfig(currentNetwork); + fetchEventData(contractId, rpcUrl, eventResponse.cursor); + } + }; + // When user toggles to show transactions, scroll the section into view useEffect(() => { if (showOnlyTransactions && txRef.current) { @@ -180,17 +229,17 @@ useEffect(() => { if (!DEBUG) return; console.log("[DBG][EscrowDetails] network:", currentNetwork); console.log("[DBG][EscrowDetails] contractId:", contractId); -}, [currentNetwork, contractId]); +}, [currentNetwork, contractId, DEBUG]); useEffect(() => { if (!DEBUG) return; console.log("[DBG][EscrowDetails] raw escrow map:", raw); -}, [raw]); +}, [raw, DEBUG]); useEffect(() => { if (!DEBUG) return; console.log("[DBG][EscrowDetails] organized data:", organized); -}, [organized]); +}, [organized, DEBUG]); useEffect(() => { if (!DEBUG) return; @@ -199,7 +248,7 @@ useEffect(() => { decimals, mismatch, }); -}, [ledgerBalance, decimals, mismatch]); +}, [ledgerBalance, decimals, mismatch, DEBUG]); return ( @@ -276,7 +325,7 @@ useEffect(() => { transition={{ delay: 0.1, duration: 0.4 }} >
-

Transaction History

+

Contract Activity

-

Complete blockchain activity record for this escrow contract

+

Recent transactions and contract events for this escrow

@@ -305,16 +354,34 @@ useEffect(() => { transition={{ duration: 25, repeat: Infinity, ease: "linear" }} />
- + setActiveTab(value as 'transactions' | 'events')} className="w-full"> + + Transactions + Recent Events + + + + + + + +
diff --git a/src/components/escrow/EventTable.tsx b/src/components/escrow/EventTable.tsx new file mode 100644 index 0000000..7ef5d23 --- /dev/null +++ b/src/components/escrow/EventTable.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { InfoTooltip } from "@/components/shared/info-tooltip"; +import { + Zap, + AlertCircle +} from "lucide-react"; +import { + type EventMetadata, +} from "@/utils/transactionFetcher"; + +interface EventTableProps { + events: EventMetadata[]; + loading: boolean; + error?: string | null; + hasMore: boolean; + onLoadMore: () => void; + isMobile: boolean; +} + +export const EventTable: React.FC = ({ + events, + loading, + error, + hasMore, + onLoadMore, + isMobile, +}) => { + const getEventTypeColor = () => { + return "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/30 dark:text-purple-200 dark:border-purple-700/40"; + }; + + if (loading && events.length === 0) { + return ( +
+
+
+ +

Recent Contract Events

+ +
+
+
+
+

Loading contract events...

+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+ +

Recent Contract Events

+
+
+ + {error} +
+
+
+ ); + } + + return ( +
+
+
+ +

Recent Contract Events

+ +
+
+

Last 7 days (RPC-limited)

+
+ + {events.length === 0 ? ( +
+
+ +
+

No Contract Events Found

+

+ This contract has not emitted any events in the last 7 days, or events are not yet available via RPC. +

+
+

Note: Contract events are only available for the last ~7 days due to RPC retention limits.

+
+
+ ) : ( +
+ {isMobile ? ( + // Mobile: Card layout +
+ {events.map((event, index) => ( + +
+
+
+
+ + Event {event.id} + +
+ + {event.type} + +
+
+
+ Ledger: + {event.ledger.toLocaleString()} +
+
+ {event.topics.length > 0 && ( +
+ Topics: +
+ {event.topics.slice(0, 2).map((topic, idx) => ( +
+ {topic.length > 20 ? `${topic.substring(0, 20)}...` : topic} +
+ ))} + {event.topics.length > 2 && ( +
+{event.topics.length - 2} more
+ )} +
+
+ )} + {event.value && ( +
+ Value: +
+ {event.value.length > 50 ? `${event.value.substring(0, 50)}...` : event.value} +
+
+ )} +
+
+ ))} +
+ ) : ( + // Desktop: Table layout +
+
+ + + + + + + + + + + + {events.map((event, index) => ( + + + + + + + + ))} + +
+ Event ID + + Type + + Ledger + + Topics + + Value +
+
+
+ + {event.id} + +
+
+ + {event.type} + + + {event.ledger.toLocaleString()} + + {event.topics.length > 0 ? ( +
+ {event.topics.slice(0, 1).map((topic, idx) => ( +
+ {topic} +
+ ))} + {event.topics.length > 1 && ( +
+{event.topics.length - 1} more
+ )} +
+ ) : ( + None + )} +
+ {event.value ? ( +
+ {event.value} +
+ ) : ( + None + )} +
+
+
+ )} + + {hasMore && ( +
+ +
+ )} +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/utils/transactionFetcher.ts b/src/utils/transactionFetcher.ts index bb720a5..03f1893 100644 --- a/src/utils/transactionFetcher.ts +++ b/src/utils/transactionFetcher.ts @@ -38,6 +38,24 @@ export interface TransactionResponse { retentionNotice?: string; } +// Types for event data +export interface EventMetadata { + id: string; + type: string; + ledger: number; + contractId: string; + topics: string[]; // base64 encoded + value: string; // base64 encoded + inSuccessfulContractCall: boolean; +} + +export interface EventResponse { + events: EventMetadata[]; + latestLedger: number; + cursor?: string; + hasMore: boolean; +} + const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org"; /** @@ -122,7 +140,7 @@ export async function fetchTransactions( } catch (error) { console.error("Error fetching transactions:", error); - + // Return graceful error response return { transactions: [], @@ -134,6 +152,81 @@ export async function fetchTransactions( } } +/** + * Fetches recent events for a contract using Soroban JSON-RPC getEvents + * Limited to ~7 days retention by RPC + */ +export async function fetchEvents( + contractId: string, + rpcUrl: string, + cursor?: string, + limit: number = 50 +): Promise { + try { + const requestBody = { + jsonrpc: "2.0", + id: 1, + method: "getEvents", + params: { + filters: [ + { + type: "contract", + contractIds: [contractId] + } + ], + cursor, + limit + } + }; + + const response = await fetch(rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error.message || "Failed to fetch events"); + } + + const result = data.result; + const events: EventMetadata[] = (result.events || []).map((event: any) => ({ + id: event.id, + type: event.type, + ledger: event.ledger, + contractId: event.contractId, + topics: event.topics || [], + value: event.value || "", + inSuccessfulContractCall: event.inSuccessfulContractCall + })); + + return { + events, + latestLedger: result.latestLedger || 0, + cursor: result.cursor, + hasMore: !!result.cursor + }; + + } catch (error) { + console.error("Error fetching events:", error); + + // Return graceful error response + return { + events: [], + latestLedger: 0, + hasMore: false + }; + } +} + /** * Fetches detailed information for a specific transaction * Returns full details with XDR decoded as JSON From 3581a69468c783dfb35ffa74ddfd8f2bd348b4fc Mon Sep 17 00:00:00 2001 From: Emmanuelluxury Date: Thu, 22 Jan 2026 13:28:05 +0100 Subject: [PATCH 2/7] fix: Add startLedger calculation for getEvents to avoid RPC error - Calculate startLedger as latestLedger - 121000 (~7 days) - Import jsonRpcCall from lib/rpc - This fixes 'startLedger must be positive' error from RPC --- src/utils/transactionFetcher.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/utils/transactionFetcher.ts b/src/utils/transactionFetcher.ts index 03f1893..5406760 100644 --- a/src/utils/transactionFetcher.ts +++ b/src/utils/transactionFetcher.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Contract } from "@stellar/stellar-sdk"; +import { jsonRpcCall } from "@/lib/rpc"; // Types for transaction data export interface TransactionMetadata { @@ -163,11 +164,18 @@ export async function fetchEvents( limit: number = 50 ): Promise { try { + // Get latest ledger to calculate startLedger for ~7 days + const latestLedgerResponse = await jsonRpcCall<{ sequence: number }>(rpcUrl, "getLatestLedger"); + const latestLedger = latestLedgerResponse.sequence; + // Approximate 7 days: ~121,000 ledgers (5 sec blocks) + const startLedger = Math.max(1, latestLedger - 121000); + const requestBody = { jsonrpc: "2.0", id: 1, method: "getEvents", params: { + startLedger, filters: [ { type: "contract", From b871a022ad5af13bd6824ca716baf32a1226910c Mon Sep 17 00:00:00 2001 From: Emmanuelluxury Date: Thu, 22 Jan 2026 13:32:54 +0100 Subject: [PATCH 3/7] fix: Resolve theme toggle hydration error - Replace custom theme logic with next-themes ThemeProvider - Add ThemeProvider to layout with proper SSR configuration - Update ThemeToggle to use useTheme hook with mounted state - Add suppressHydrationWarning to html element - Fixes server/client mismatch in theme state --- src/app/layout.tsx | 16 +++++++--- src/components/ui/theme-toggle.tsx | 50 ++++++++++++++++++------------ 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bbe7d3d..c26201b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Suspense } from 'react' import { NetworkProvider } from "@/contexts/NetworkContext"; +import { ThemeProvider } from "next-themes"; const geistSans = Geist({ @@ -33,15 +34,22 @@ export default function RootLayout({ children: React.ReactNode }) { return ( - + Loading...
}> - - {children} - + + + {children} + + diff --git a/src/components/ui/theme-toggle.tsx b/src/components/ui/theme-toggle.tsx index 4f255a8..3eafb6d 100644 --- a/src/components/ui/theme-toggle.tsx +++ b/src/components/ui/theme-toggle.tsx @@ -1,31 +1,41 @@ "use client"; +import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; export function ThemeToggle() { - const [isDark, setIsDark] = useState(() => { - try { - if (typeof window === "undefined") return false; - const stored = localStorage.getItem("theme"); - if (stored) return stored === "dark"; - return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; - } catch { - return false; - } - }); + const { theme, setTheme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); useEffect(() => { - const root = document.documentElement; - if (isDark) root.classList.add("dark"); - else root.classList.remove("dark"); - try { - localStorage.setItem("theme", isDark ? "dark" : "light"); - } catch { - // ignore - } - }, [isDark]); + setMounted(true); + }, []); - const toggle = () => setIsDark((v) => !v); + if (!mounted) { + return ( + + ); + } + + const isDark = resolvedTheme === "dark"; + const toggle = () => setTheme(theme === "dark" ? "light" : "dark"); return ( - - )} +
+
+
- )} + + {raw && ( +
+ +
+ )} +
{/* Error Display */} - + - {/* Content Section (hidden when showing transactions as a page) */} - {!showOnlyTransactions && ( - - )} + {/* Content Section */} + {/* Live ledger balance (from token contract) */} - {!showOnlyTransactions && raw && ledgerBalance && ( + {raw && ledgerBalance && ( )} - {/* Transaction History Section (renders only when requested) */} - {raw && showOnlyTransactions && ( - -
-

Contract Activity

-
- -
-
- -
-

Recent transactions and contract events for this escrow

-
- -
- - -
- setActiveTab(value as 'transactions' | 'events')} className="w-full"> - - Transactions - Recent Events - - - - - - - - -
-
-
-
- )} {/* Transaction Detail Modal */} { isOpen={isModalOpen} onClose={handleModalClose} isMobile={isMobile} + network={currentNetwork} + /> + + {/* Transaction History Modal */} + setIsHistoryModalOpen(false)} + isMobile={isMobile} + transactions={transactions} + transactionLoading={transactionLoading} + transactionError={transactionError} + transactionResponse={transactionResponse} + onLoadMoreTransactions={handleLoadMoreTransactions} + onTransactionClick={handleTransactionClick} + events={events} + eventLoading={eventLoading} + eventError={eventError} + eventResponse={eventResponse} + onLoadMoreEvents={handleLoadMoreEvents} /> diff --git a/src/components/escrow/TransactionDetailModal.tsx b/src/components/escrow/TransactionDetailModal.tsx index 34cd8d2..dee8f07 100644 --- a/src/components/escrow/TransactionDetailModal.tsx +++ b/src/components/escrow/TransactionDetailModal.tsx @@ -29,12 +29,14 @@ import { formatTransactionTime, truncateHash, } from "@/utils/transactionFetcher"; +import { type NetworkType } from "@/lib/network-config"; interface TransactionDetailModalProps { txHash: string | null; isOpen: boolean; onClose: () => void; isMobile: boolean; + network: NetworkType; } export const TransactionDetailModal: React.FC = ({ @@ -42,6 +44,7 @@ export const TransactionDetailModal: React.FC = ({ isOpen, onClose, isMobile, + network, }) => { const [details, setDetails] = useState(null); const [loading, setLoading] = useState(false); @@ -54,7 +57,7 @@ export const TransactionDetailModal: React.FC = ({ setError(null); try { - const transactionDetails = await fetchTransactionDetails(txHash); + const transactionDetails = await fetchTransactionDetails(txHash, network); setDetails(transactionDetails); } catch (err) { setError("Failed to fetch transaction details"); @@ -62,7 +65,7 @@ export const TransactionDetailModal: React.FC = ({ } finally { setLoading(false); } - }, [txHash]); + }, [txHash, network]); const copyToClipboard = async (text: string) => { try { diff --git a/src/components/escrow/TransactionHistoryModal.tsx b/src/components/escrow/TransactionHistoryModal.tsx new file mode 100644 index 0000000..c73862e --- /dev/null +++ b/src/components/escrow/TransactionHistoryModal.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState } from "react"; +import { motion } from "framer-motion"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { TransactionTable } from "@/components/escrow/TransactionTable"; +import { EventTable } from "@/components/escrow/EventTable"; +import { + type TransactionMetadata, + type TransactionResponse, + type EventMetadata, + type EventResponse, +} from "@/utils/transactionFetcher"; +import { type NetworkType } from "@/lib/network-config"; + +interface TransactionHistoryModalProps { + isOpen: boolean; + onClose: () => void; + isMobile: boolean; + // Transaction data + transactions: TransactionMetadata[]; + transactionLoading: boolean; + transactionError: string | null; + transactionResponse: TransactionResponse | null; + onLoadMoreTransactions: () => void; + onTransactionClick: (txHash: string) => void; + // Event data + events: EventMetadata[]; + eventLoading: boolean; + eventError: string | null; + eventResponse: EventResponse | null; + onLoadMoreEvents: () => void; +} + +export const TransactionHistoryModal: React.FC = ({ + isOpen, + onClose, + isMobile, + transactions, + transactionLoading, + transactionError, + transactionResponse, + onLoadMoreTransactions, + onTransactionClick, + events, + eventLoading, + eventError, + eventResponse, + onLoadMoreEvents, +}) => { + const [activeTab, setActiveTab] = useState<'transactions' | 'events'>('transactions'); + + return ( + + + + Contract Activity + + Recent transactions and contract events for this escrow + + + +
+ setActiveTab(value as 'transactions' | 'events')} className="w-full h-full"> + + Transactions + Recent Events + + + +
+ +
+
+ + +
+ +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/escrow/error-display.tsx b/src/components/escrow/error-display.tsx index 2a4e452..dccb5fe 100644 --- a/src/components/escrow/error-display.tsx +++ b/src/components/escrow/error-display.tsx @@ -1,11 +1,16 @@ import { motion, AnimatePresence } from "framer-motion" -import { AlertCircle } from "lucide-react" +import { AlertCircle, RefreshCw } from "lucide-react" +import { Button } from "@/components/ui/button" interface ErrorDisplayProps { error: string | null + onSwitchNetwork?: () => void + switchNetworkLabel?: string } -export const ErrorDisplay = ({ error }: ErrorDisplayProps) => { +export const ErrorDisplay = ({ error, onSwitchNetwork, switchNetworkLabel }: ErrorDisplayProps) => { + const isContractNotFound = error?.includes("Contract not found") && error?.includes("Try switching to") + return ( {error && ( @@ -13,10 +18,27 @@ export const ErrorDisplay = ({ error }: ErrorDisplayProps) => { initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} - className="max-w-2xl mx-auto mb-6 p-4 bg-red-50 text-red-800 rounded-md flex items-center gap-2 shadow-sm border border-red-100" + className="max-w-2xl mx-auto mb-6 p-4 bg-red-50 text-red-800 rounded-md shadow-sm border border-red-100" > - -

{error}

+
+ +
+

{error}

+ {isContractNotFound && onSwitchNetwork && switchNetworkLabel && ( +
+ +
+ )} +
+
)}
diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 0000000..9cd7867 --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,4 @@ +declare module '*.css' { + const content: any; + export default content; +} \ No newline at end of file diff --git a/src/hooks/useEscrowData.ts b/src/hooks/useEscrowData.ts index 0b9b41d..a744e60 100644 --- a/src/hooks/useEscrowData.ts +++ b/src/hooks/useEscrowData.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { getLedgerKeyContractCode, type EscrowMap } from "@/utils/ledgerkeycontract"; import { organizeEscrowData, type OrganizedEscrowData } from "@/mappers/escrow-mapper"; -import type { NetworkType } from "@/lib/network-config"; +import { getNetworkConfig, type NetworkType } from "@/lib/network-config"; /** * Loads raw escrow contract storage and maps it to OrganizedEscrowData for UI. @@ -34,16 +34,24 @@ export function useEscrowData(contractId: string, network: NetworkType, isMobile try { const data = await getLedgerKeyContractCode(contractId, network); - setRaw(data); - setOrganized(organizeEscrowData(data, contractId, isMobile)); + if (data === null) { + setRaw(null); + setOrganized(null); + const otherNetwork = network === 'testnet' ? 'mainnet' : 'testnet'; + setError(`Contract not found on ${getNetworkConfig(network).name}. Try switching to ${getNetworkConfig(otherNetwork).name} or verify the contract ID is correct.`); + } else { + setRaw(data); + setOrganized(organizeEscrowData(data, contractId, isMobile)); + setError(null); + } } catch (e) { setRaw(null); setOrganized(null); let errorMessage = "Failed to fetch escrow data"; if (e instanceof Error) { - if (e.message.includes("No ledger entry found")) { - errorMessage = "Contract not found. Please check the contract ID and network."; + if (e.message.includes("Failed to fetch")) { + errorMessage = `Network error: Unable to connect to ${getNetworkConfig(network).name}. Please check your internet connection and try again.`; } else if (e.message.includes("Invalid contract ID")) { errorMessage = "Invalid contract ID format. Please enter a valid Soroban contract ID."; } else { diff --git a/src/lib/network-config.ts b/src/lib/network-config.ts index d820a40..3ed5fd5 100644 --- a/src/lib/network-config.ts +++ b/src/lib/network-config.ts @@ -16,7 +16,7 @@ export const NETWORK_CONFIGS: Record = { }, mainnet: { name: 'Mainnet', - rpcUrl: 'https://stellar.api.onfinality.io/public', + rpcUrl: 'https://soroban-mainnet.stellar.org', horizonUrl: 'https://horizon.stellar.org', networkPassphrase: 'Public Global Stellar Network ; September 2015' } diff --git a/src/utils/ledgerkeycontract.ts b/src/utils/ledgerkeycontract.ts index bf990db..75c002c 100644 --- a/src/utils/ledgerkeycontract.ts +++ b/src/utils/ledgerkeycontract.ts @@ -31,7 +31,7 @@ interface StorageEntry { export async function getLedgerKeyContractCode( contractId: string, network: NetworkType = 'testnet' -): Promise { +): Promise { try { const ledgerKey = new Contract(contractId).getFootprint(); const keyBase64 = ledgerKey.toXDR("base64"); @@ -47,27 +47,42 @@ export async function getLedgerKeyContractCode( }; const networkConfig = getNetworkConfig(network); - const res = await fetch(networkConfig.rpcUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); + let res; + try { + res = await fetch(networkConfig.rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + } catch (error) { + console.warn("Failed to fetch ledger entries from RPC:", error); + return null; + } if (!res.ok) { - throw new Error(`HTTP error! Status: ${res.status}`); + console.warn(`HTTP error fetching ledger entries: Status ${res.status}`); + return null; } - const json = await res.json(); + let json; + try { + json = await res.json(); + } catch (error) { + console.warn("Failed to parse JSON response:", error); + return null; + } if (json.error) { - throw new Error(json.error.message || "Failed to fetch ledger entries"); + console.warn("RPC error:", json.error.message || "Failed to fetch ledger entries"); + return null; } const entry = json.result.entries[0]; if (!entry) { - throw new Error("No ledger entry found for this contract ID"); + // Contract not found - this is a valid response, not an error + return null; } const contractData = entry?.dataJson?.contract_data?.val?.contract_instance; @@ -102,6 +117,7 @@ export async function getLedgerKeyContractCode( return escrowEntry.val.map as EscrowMap; } catch (error) { console.error("Error fetching escrow data:", error); - return []; + // Re-throw network and other errors, only return null for "contract not found" + throw error; } } diff --git a/src/utils/transactionFetcher.ts b/src/utils/transactionFetcher.ts index 62ec7ab..9f03a16 100644 --- a/src/utils/transactionFetcher.ts +++ b/src/utils/transactionFetcher.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Contract } from "@stellar/stellar-sdk"; import { jsonRpcCall } from "@/lib/rpc"; +import { getNetworkConfig, type NetworkType } from "@/lib/network-config"; // Types for transaction data export interface TransactionMetadata { @@ -57,7 +58,6 @@ export interface EventResponse { hasMore: boolean; } -const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org"; /** * Fetches recent transactions for a contract using Soroban JSON-RPC @@ -65,10 +65,17 @@ const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL || "https://soro */ export async function fetchTransactions( contractId: string, + network: NetworkType, options: FetchTransactionsOptions = {} ): Promise { try { + // Basic validation for contract ID format + if (!/^C[A-Z0-9]{55}$/.test(contractId)) { + throw new Error("Invalid contract ID format. Contract IDs should start with 'C' followed by 55 alphanumeric characters."); + } + const { startLedger, cursor, limit = 50 } = options; + const networkConfig = getNetworkConfig(network); // Get contract instance to derive transaction filter const contract = new Contract(contractId); @@ -91,19 +98,50 @@ export async function fetchTransactions( } }; - const response = await fetch(SOROBAN_RPC_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }); + let response; + try { + response = await fetch(networkConfig.rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + } catch (error) { + console.warn("Failed to fetch transactions from RPC:", error); + return { + transactions: [], + latestLedger: 0, + oldestLedger: 0, + hasMore: false, + retentionNotice: "Unable to connect to RPC. Please check your internet connection." + }; + } if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); + console.warn(`HTTP error fetching transactions: Status ${response.status}`); + return { + transactions: [], + latestLedger: 0, + oldestLedger: 0, + hasMore: false, + retentionNotice: "Unable to connect to RPC. Please check your internet connection." + }; } - const data = await response.json(); + let data; + try { + data = await response.json(); + } catch (error) { + console.warn("Failed to parse JSON response:", error); + return { + transactions: [], + latestLedger: 0, + oldestLedger: 0, + hasMore: false, + retentionNotice: "Unable to connect to RPC. Please check your internet connection." + }; + } if (data.error) { // Handle retention-related errors gracefully @@ -116,7 +154,14 @@ export async function fetchTransactions( retentionNotice: "Transaction data beyond retention period. RPC typically retains 24h-7 days of history." }; } - throw new Error(data.error.message || "Failed to fetch transactions"); + console.warn("RPC error:", data.error.message || "Failed to fetch transactions"); + return { + transactions: [], + latestLedger: 0, + oldestLedger: 0, + hasMore: false, + retentionNotice: "Unable to connect to RPC. Please check your internet connection." + }; } const result = data.result; @@ -164,6 +209,11 @@ export async function fetchEvents( limit: number = 50 ): Promise { try { + // Basic validation for contract ID format + if (!/^C[A-Z0-9]{55}$/.test(contractId)) { + throw new Error("Invalid contract ID format. Contract IDs should start with 'C' followed by 55 alphanumeric characters."); + } + // Build request params const params: any = { filters: [ @@ -194,24 +244,35 @@ export async function fetchEvents( } const makeRequest = async (requestParams: any) => { - const response = await fetch(rpcUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "getEvents", - params: requestParams - }), - }); + try { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "getEvents", + params: requestParams + }), + }); + + if (!response.ok) { + console.warn(`HTTP error fetching events: Status ${response.status}`); + return { error: { message: "Unable to connect to RPC. Please check your internet connection." } }; + } - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); + try { + return await response.json(); + } catch (error) { + console.warn("Failed to parse JSON response:", error); + return { error: { message: "Unable to connect to RPC. Please check your internet connection." } }; + } + } catch (error) { + console.warn("Failed to fetch events from RPC:", error); + return { error: { message: "Unable to connect to RPC. Please check your internet connection." } }; } - - return await response.json(); }; let data = await makeRequest(params); @@ -224,6 +285,14 @@ export async function fetchEvents( } if (data.error) { + if (data.error.message?.includes("Unable to connect")) { + return { + events: [], + latestLedger: 0, + cursor: undefined, + hasMore: false + }; + } throw new Error(data.error.message || "Failed to fetch events"); } @@ -261,8 +330,9 @@ export async function fetchEvents( * Fetches detailed information for a specific transaction * Returns full details with XDR decoded as JSON */ -export async function fetchTransactionDetails(txHash: string): Promise { +export async function fetchTransactionDetails(txHash: string, network: NetworkType): Promise { try { + const networkConfig = getNetworkConfig(network); const requestBody = { jsonrpc: "2.0", id: 2, @@ -272,7 +342,7 @@ export async function fetchTransactionDetails(txHash: string): Promise