diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d8e53ec..2fb5522 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"; // Work around Node.js experimental localStorage mismatch in dev server // (prevents Next dev overlay from crashing when localStorage is non-standard) @@ -52,13 +53,12 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + Loading...}> - {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index a1f4636..47a78f4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,16 +12,26 @@ import { EXAMPLE_CONTRACT_IDS } from "@/lib/escrow-constants"; import type { NextPage } from "next"; import { useRouter } from "next/navigation"; import { useNetwork } from "@/contexts/NetworkContext"; +import { getNetworkConfig } from "@/lib/network-config"; const inter = Inter({ subsets: ["latin"] }); const Home: NextPage = () => { const router = useRouter(); - const { currentNetwork } = useNetwork(); + const { currentNetwork, setNetwork } = useNetwork(); const [contractId, setContractId] = useState(""); const [error, setError] = useState(null); const [isSearchFocused, setIsSearchFocused] = useState(false); + // Network switching for error recovery + const otherNetwork = currentNetwork === 'testnet' ? 'mainnet' : 'testnet'; + const switchNetworkLabel = `Try ${getNetworkConfig(otherNetwork).name}`; + const handleSwitchNetwork = () => { + setNetwork(otherNetwork); + // Clear error when switching networks + setError(null); + }; + // Responsive: detect mobile for SearchCard behaviour const handleNavigate = async () => { @@ -53,62 +63,6 @@ const Home: NextPage = () => {
- {/* Left: text */} -
- - Escrow Data Viewer - - - - View detailed information about any escrow contract on the - Stellar blockchain. - - -
- {/* Keep the existing search card for discoverability */} - - - -
-
- - {/* Right: image (flexes) */} - -
- Escrow Viewer illustration -
-
-
diff --git a/src/components/escrow/EscrowDetails.tsx b/src/components/escrow/EscrowDetails.tsx index fb583bb..3409dfe 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,16 @@ 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 { TransactionHistoryModal } from "@/components/escrow/TransactionHistoryModal"; 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"; @@ -61,6 +68,16 @@ const EscrowDetailsClient: React.FC = ({ currentNetwork, ); + // Network switching for error recovery + const { setNetwork } = useNetwork(); + const otherNetwork = currentNetwork === 'testnet' ? 'mainnet' : 'testnet'; + const switchNetworkLabel = `Try ${getNetworkConfig(otherNetwork).name}`; + const handleSwitchNetwork = () => { + setNetwork(otherNetwork); + router.push(`/${contractId}`); + // The network change will trigger a re-fetch automatically + }; + const organizedWithLive = useMemo(() => { if (!organized) return null; if (!ledgerBalance) return organized; // nothing to override @@ -81,9 +98,6 @@ const EscrowDetailsClient: React.FC = ({ useState(null); const [selectedTxHash, setSelectedTxHash] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const [showOnlyTransactions, setShowOnlyTransactions] = - useState(false); - const txRef = useRef(null); // Fetch transaction data const fetchTransactionData = useCallback( @@ -92,7 +106,7 @@ const EscrowDetailsClient: React.FC = ({ setTransactionLoading(true); setTransactionError(null); try { - const response = await fetchTransactions(id, { cursor, limit: 20 }); + const response = await fetchTransactions(id, currentNetwork, { cursor, limit: 20 }); setTransactionResponse(response); if (cursor) { setTransactions((prev) => [...prev, ...response.transactions]); @@ -105,15 +119,16 @@ const EscrowDetailsClient: React.FC = ({ setTransactionLoading(false); } }, - [], ); - // Initial + network-change fetch (escrow + txs) + // 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) => { @@ -124,6 +139,8 @@ const EscrowDetailsClient: React.FC = ({ } void refresh(); fetchTransactionData(contractId); + const { rpcUrl } = getNetworkConfig(currentNetwork); + fetchEventData(contractId, rpcUrl); } }; @@ -137,12 +154,13 @@ const EscrowDetailsClient: React.FC = ({ // Fetch button click const handleFetch = async () => { - if (!contractId) return; if (contractId !== initialEscrowId) { router.push(`/${contractId}`); } await refresh(); fetchTransactionData(contractId); + const { rpcUrl } = getNetworkConfig(currentNetwork); + fetchEventData(contractId, rpcUrl); }; // Transactions UI handlers @@ -159,37 +177,13 @@ const EscrowDetailsClient: React.FC = ({ fetchTransactionData(contractId, transactionResponse.cursor); } }; - - // When user toggles to show transactions, scroll the section into view - useEffect(() => { - if (showOnlyTransactions && txRef.current) { - try { - txRef.current.scrollIntoView({ behavior: "smooth", block: "start" }); - } catch { - // ignore scroll failures - } } - }, [showOnlyTransactions]); + }; + // === DEBUG LOGGING (EscrowDetails) === const DEBUG = true; - useEffect(() => { - if (!DEBUG) return; - console.log("[DBG][EscrowDetails] network:", currentNetwork); - console.log("[DBG][EscrowDetails] contractId:", contractId); - }, [currentNetwork, contractId]); - - useEffect(() => { - if (!DEBUG) return; - console.log("[DBG][EscrowDetails] raw escrow map:", raw); - }, [raw]); - - useEffect(() => { - if (!DEBUG) return; - console.log("[DBG][EscrowDetails] organized data:", organized); - }, [organized]); - useEffect(() => { if (!DEBUG) return; console.log("[DBG][EscrowDetails] token live balance:", { @@ -223,8 +217,6 @@ const EscrowDetailsClient: React.FC = ({ )} {/* Search Card + View Transactions button (flexed together) */} - {!showOnlyTransactions && ( -
= ({ handleKeyDown={handleKeyDown} fetchEscrowData={handleFetch} handleUseExample={handleUseExample} - raw={raw} - organized={organized} - initialEscrowId={initialEscrowId} - currentNetwork={currentNetwork} - setShowOnlyTransactions={setShowOnlyTransactions} />
- )} - - {/* Error Display */} - - {/* Content Section (hidden when showing transactions as a page) */} - {!showOnlyTransactions && ( - - )} - - {/* Live ledger balance (from token contract) */} - {!showOnlyTransactions && raw && ledgerBalance && ( - - )} - - {/* Transaction History Section (renders only when requested) */} - {raw && showOnlyTransactions && ( - -
-

- Transaction History -

-
- -
-
- -
-

- Complete blockchain activity record for this escrow contract -

-
- -
- +
-
- )} + )} + + + {/* Error Display */} + {/* 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/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/components/escrow/TransactionDetailModal.tsx b/src/components/escrow/TransactionDetailModal.tsx index d941451..2a72a0d 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 ec1d63a..a8bede2 100644 --- a/src/components/escrow/error-display.tsx +++ b/src/components/escrow/error-display.tsx @@ -1,11 +1,9 @@ -import { motion, AnimatePresence } from "framer-motion"; -import { AlertCircle } from "lucide-react"; -interface ErrorDisplayProps { - error: string | null; } -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 +11,26 @@ 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 dark:bg-red-500/10 text-red-800 dark:text-red-300 rounded-md flex items-center gap-2 shadow-sm border border-red-100 dark:border-red-500/30" > - -

{error}

+
+ +
+

{error}

+ {isContractNotFound && onSwitchNetwork && switchNetworkLabel && ( +
+ +
+ )} +
+
)}
diff --git a/src/components/ui/theme-toggle.tsx b/src/components/ui/theme-toggle.tsx index f63cc05..8906e99 100644 --- a/src/components/ui/theme-toggle.tsx +++ b/src/components/ui/theme-toggle.tsx @@ -1,41 +1,40 @@ "use client"; +import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { safeLocalStorage } from "@/utils/storage"; import { cn } from "@/lib/utils"; export function ThemeToggle() { - const [isDark, setIsDark] = useState(false); - const [mounted, setMounted] = useState(false); - - // Only access localStorage after component mounts (client-side only) - useEffect(() => { - setMounted(true); - const stored = safeLocalStorage.getItem("theme"); - if (stored) { - setIsDark(stored === "dark"); - } else if ( - typeof window !== "undefined" && - window.matchMedia && - window.matchMedia("(prefers-color-scheme: dark)").matches - ) { - setIsDark(true); - } - }, []); // Apply theme class to document useEffect(() => { - if (!mounted) return; - const root = document.documentElement; - if (isDark) { - root.classList.add("dark"); - } else { - root.classList.remove("dark"); - } - safeLocalStorage.setItem("theme", isDark ? "dark" : "light"); - }, [isDark, mounted]); - const toggle = () => setIsDark((v) => !v); + if (!mounted) { + return ( + + ); + } + + const isDark = resolvedTheme === "dark"; + const toggle = () => setTheme(theme === "dark" ? "light" : "dark"); // Avoid hydration mismatch by not rendering until mounted if (!mounted) { 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 65eabf0..956e01f 100644 --- a/src/hooks/useEscrowData.ts +++ b/src/hooks/useEscrowData.ts @@ -1,14 +1,5 @@ // src/hooks/useEscrowData.ts 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"; /** * Loads raw escrow contract storage and maps it to OrganizedEscrowData for UI. @@ -25,18 +16,51 @@ export function useEscrowData( const [error, setError] = useState(null); const refresh = useCallback(async () => { - if (!contractId) return; + if (!contractId) { + setError("Please enter a contract ID"); + return; + } + + // Basic validation for contract ID format + if (!/^C[A-Z0-9]{55}$/.test(contractId)) { + setError("Invalid contract ID format. Contract IDs should start with 'C' followed by 55 alphanumeric characters."); + setRaw(null); + setOrganized(null); + setLoading(false); + return; + } + setLoading(true); setError(null); 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); - setError(e instanceof Error ? e.message : "Failed to fetch escrow data"); + let errorMessage = "Failed to fetch escrow data"; + + if (e instanceof Error) { + 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 { + errorMessage = e.message; + } + } + + setError(errorMessage); } finally { setLoading(false); } diff --git a/src/lib/network-config.ts b/src/lib/network-config.ts index a23032a..8c23a8f 100644 --- a/src/lib/network-config.ts +++ b/src/lib/network-config.ts @@ -15,11 +15,7 @@ export const NETWORK_CONFIGS: Record = { networkPassphrase: "Test SDF Network ; September 2015", }, mainnet: { - name: "Mainnet", - rpcUrl: "https://stellar.api.onfinality.io/public", - horizonUrl: "https://horizon.stellar.org", - networkPassphrase: "Public Global Stellar Network ; September 2015", - }, + }; export function getNetworkConfig(network: NetworkType): NetworkConfig { diff --git a/src/utils/ledgerkeycontract.ts b/src/utils/ledgerkeycontract.ts index 8ec3f19..8cf6519 100644 --- a/src/utils/ledgerkeycontract.ts +++ b/src/utils/ledgerkeycontract.ts @@ -30,8 +30,6 @@ interface StorageEntry { export async function getLedgerKeyContractCode( contractId: string, - network: NetworkType = "testnet", -): Promise { try { const ledgerKey = new Contract(contractId).getFootprint(); const keyBase64 = ledgerKey.toXDR("base64"); @@ -47,27 +45,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; @@ -105,6 +118,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 f4f45c3..106ae27 100644 --- a/src/utils/transactionFetcher.ts +++ b/src/utils/transactionFetcher.ts @@ -1,5 +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 { @@ -38,9 +40,6 @@ export interface TransactionResponse { retentionNotice?: string; } -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 @@ -48,10 +47,16 @@ const SOROBAN_RPC_URL = */ export async function fetchTransactions( contractId: string, - options: FetchTransactionsOptions = {}, +in ): 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); @@ -74,19 +79,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 @@ -103,7 +139,14 @@ export async function fetchTransactions( "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; @@ -143,14 +186,140 @@ 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 { + // 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: [ + { + type: "contract", + contractIds: [contractId] + } + ], + cursor, + limit + }; + + // Only add startLedger if we can calculate a reasonable recent range + // RPC typically retains ~7 days, but we try to be more conservative + try { + const latestLedgerResponse = await jsonRpcCall<{ sequence: number }>(rpcUrl, "getLatestLedger"); + const latestLedger = latestLedgerResponse.sequence; + // Conservative estimate: ~3 days (5 sec blocks * 86400 sec/day * 3 days) + const estimatedStartLedger = Math.max(1, latestLedger - 51840); + + // Only use startLedger if it's reasonably recent (avoid invalid range errors) + if (estimatedStartLedger > latestLedger - 100000) { // Within last ~6 days + params.startLedger = estimatedStartLedger; + } + } catch (ledgerError) { + // If we can't get latest ledger, proceed without startLedger + console.warn("Could not fetch latest ledger for events, using default range:", ledgerError); + } + + const makeRequest = async (requestParams: any) => { + 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." } }; + } + + 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." } }; + } + }; + + let data = await makeRequest(params); + + // If startLedger is out of range, retry without it + if (data.error && data.error.message?.includes("startLedger must be within")) { + console.warn("startLedger out of range, retrying without startLedger"); + delete params.startLedger; + data = await makeRequest(params); + } + + 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"); + } + + 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 */ -export async function fetchTransactionDetails( - txHash: string, -): Promise { try { + const networkConfig = getNetworkConfig(network); const requestBody = { jsonrpc: "2.0", id: 2, @@ -160,7 +329,7 @@ export async function fetchTransactionDetails( }, }; - const response = await fetch(SOROBAN_RPC_URL, { + const response = await fetch(networkConfig.rpcUrl, { method: "POST", headers: { "Content-Type": "application/json",