From 7e0508b7a7026957e7dfb4399a847aba60b8a1c7 Mon Sep 17 00:00:00 2001 From: bitstarkbridge Date: Thu, 22 Jan 2026 15:45:22 +0000 Subject: [PATCH 1/4] Add Recent Contract Events section - Implement useRecentEvents hook to fetch Soroban contract events via Stellar RPC - Add Recent Contract Events section in Transaction History view - Display events with type, ledger, ID, topics, and value (base64) - Handle loading, error, and empty states - Include disclosure about 7-day RPC limitation - Events ordered most recent first --- package-lock.json | 41 ++++++------ src/components/escrow/EscrowDetails.tsx | 83 +++++++++++++++++++++++ src/hooks/useRecentEvents.ts | 89 +++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 21 deletions(-) create mode 100644 src/hooks/useRecentEvents.ts diff --git a/package-lock.json b/package-lock.json index ff4af88..37ce709 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,10 +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==", - "license": "MIT" + "version": "15.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.8.tgz", + "integrity": "sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q==" }, "node_modules/@next/eslint-plugin-next": { "version": "15.3.6", @@ -3375,6 +3374,7 @@ "integrity": "sha512-vrdxRZfo9ALXth6yPfV16PYTLZwsUWhVjjC+DkfE5t1suNSbBrWC9YqSuuxJZ8Ps6z1o2ycRpIqzZJIgklq4Tw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3385,6 +3385,7 @@ "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3425,6 +3426,7 @@ "integrity": "sha512-w6HZUV4NWxqd8BdeFf81t07d7/YV9s7TCWrQQbG5uhuvGUAW+fq1usZ1Hmz9UPNLniFnD8GLSsDpjP0hm1S4lQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.26.1", "@typescript-eslint/types": "8.26.1", @@ -3788,6 +3790,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4166,16 +4169,6 @@ "license": "Apache-2.0", "optional": true }, - "node_modules/bare-url": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.1.3.tgz", - "integrity": "sha512-c02+eKvn/4esh5E2lSYQFwHL1WoTIL3u3NeFqb9e7ahBVENXw13MWx4/4/wdPyI557GqqB2Cm0bBbOXD0I0qgA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, "node_modules/base32.js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", @@ -4841,6 +4834,7 @@ "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5015,6 +5009,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -6802,12 +6797,11 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.3.6", - "resolved": "https://registry.npmjs.org/next/-/next-15.3.6.tgz", - "integrity": "sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w==", - "license": "MIT", + "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==", "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", @@ -7254,6 +7248,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7263,6 +7258,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8045,7 +8041,8 @@ "version": "4.0.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.14.tgz", "integrity": "sha512-92YT2dpt671tFiHH/e1ok9D987N9fHD5VWoly1CdPD/Cd1HMglvZwP3nx2yTj2lbXDAHt8QssZkxTLCCTNL+xw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -8104,6 +8101,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8265,6 +8263,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/components/escrow/EscrowDetails.tsx b/src/components/escrow/EscrowDetails.tsx index 776fe90..d6d4bfa 100644 --- a/src/components/escrow/EscrowDetails.tsx +++ b/src/components/escrow/EscrowDetails.tsx @@ -29,6 +29,7 @@ import { useIsMobile } from "@/hooks/useIsMobile"; // ⬇️ New hooks import { useEscrowData } from "@/hooks/useEscrowData"; import { useTokenBalance } from "@/hooks/useTokenBalance"; +import { useRecentEvents } from "@/hooks/useRecentEvents"; // (useMemo is consolidated in the import above) @@ -63,6 +64,12 @@ const isMobile = useIsMobile(); currentNetwork ); + // Recent events hook + const { events, loading: eventsLoading, error: eventsError } = useRecentEvents( + contractId, + currentNetwork + ); + const organizedWithLive = useMemo(() => { if (!organized) return null; if (!ledgerBalance) return organized; // nothing to override @@ -318,6 +325,82 @@ useEffect(() => { + + {/* Recent Contract Events Section */} + +
+

Recent Contract Events

+
+ +
+

Last 7 days (RPC-limited)

+

+ This view shows only events available via Stellar RPC (last ~7 days). + Historical events will be available in future versions. +

+
+ +
+ + +
+ {eventsLoading ? ( +
+

Loading recent events...

+
+ ) : eventsError ? ( +
+

Unable to fetch recent events from Stellar RPC.

+
+ ) : events.length === 0 ? ( +
+

No contract events found in the last 7 days.

+
+ ) : ( +
+ {events.map((event) => ( +
+
+ Event Type: {event.type} + Ledger: {event.ledger} +
+
+ ID: {event.id} +
+
+ Topics: +
+ {event.topics.join(', ')} +
+
+
+ Value: +
+ {event.value} +
+
+
+ ))} +
+ )} +
+
+
+
)} diff --git a/src/hooks/useRecentEvents.ts b/src/hooks/useRecentEvents.ts new file mode 100644 index 0000000..645bede --- /dev/null +++ b/src/hooks/useRecentEvents.ts @@ -0,0 +1,89 @@ +import { useState, useEffect, useCallback } from 'react'; +import { SorobanRpc } from '@stellar/stellar-sdk'; +import { getNetworkConfig, type NetworkType } from '@/lib/network-config'; + +export interface ContractEvent { + id: string; + type: string; + ledger: number; + contractId: string; + topics: string[]; // base64 or decoded + value: string; // base64 or decoded +} + +export interface UseRecentEventsResult { + events: ContractEvent[]; + loading: boolean; + error: string | null; + refetch: () => void; +} + +export function useRecentEvents( + contractId: string, + network: NetworkType +): UseRecentEventsResult { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchEvents = useCallback(async () => { + if (!contractId) return; + + setLoading(true); + setError(null); + + try { + const config = getNetworkConfig(network); + const server = new SorobanRpc.Server(config.rpcUrl); + + // Get latest ledger + const latestLedger = await server.getLatestLedger(); + const endLedger = latestLedger.sequence; + + // Approximate 7 days: ~5 seconds per ledger, 7*24*3600/5 ≈ 12096 + const sevenDaysLedgers = Math.floor((7 * 24 * 3600) / 5); + const startLedger = Math.max(1, endLedger - sevenDaysLedgers); + + const response = await server.getEvents({ + startLedger, + filters: [ + { + contractIds: [contractId], + type: 'contract', + }, + ], + limit: 100, // reasonable limit + }); + + // Map to our interface + const mappedEvents: ContractEvent[] = response.events.map((event) => ({ + id: event.id, + type: event.type, + ledger: event.ledger, + contractId: event.contractId, + topics: event.topic.map((topic) => topic.toString('base64')), // keep as base64 for now + value: event.value.toString('base64'), // keep as base64 + })); + + // Sort by ledger descending (most recent first) + mappedEvents.sort((a, b) => b.ledger - a.ledger); + + setEvents(mappedEvents); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to fetch recent events'); + } finally { + setLoading(false); + } + }, [contractId, network]); + + useEffect(() => { + fetchEvents(); + }, [fetchEvents]); + + return { + events, + loading, + error, + refetch: fetchEvents, + }; +} \ No newline at end of file From a2c00060b86b5fa17398de48f9bf2b92a56bc030 Mon Sep 17 00:00:00 2001 From: bitstarkbridge Date: Thu, 22 Jan 2026 16:30:52 +0000 Subject: [PATCH 2/4] Force push frontend code --- src/components/escrow/error-display.tsx | 39 +++++++++++++++++------- src/components/escrow/escrow-content.tsx | 5 ++- src/components/escrow/welcome-state.tsx | 12 +++++--- src/components/ui/theme-toggle.tsx | 25 +++++++++------ src/hooks/useEscrowData.ts | 35 +++++++++++++++++++-- src/hooks/useRecentEvents.ts | 16 +++++----- src/lib/escrow-constants.ts | 2 +- src/lib/network-config.ts | 2 +- src/utils/ledgerkeycontract.ts | 2 +- 9 files changed, 98 insertions(+), 40 deletions(-) diff --git a/src/components/escrow/error-display.tsx b/src/components/escrow/error-display.tsx index 2a4e452..b00ce76 100644 --- a/src/components/escrow/error-display.tsx +++ b/src/components/escrow/error-display.tsx @@ -6,19 +6,36 @@ interface ErrorDisplayProps { } export const ErrorDisplay = ({ error }: ErrorDisplayProps) => { + if (!error) return null; + + const parts = error.split('•').map(part => part.trim()).filter(part => part); + return ( - {error && ( - - -

{error}

-
- )} + +
+ +
+ {parts.length > 1 ? ( +
+

{parts[0]}

+
    + {parts.slice(1).map((line, index) => ( +
  • {line}
  • + ))} +
+
+ ) : ( +

{error}

+ )} +
+
+
) } \ No newline at end of file diff --git a/src/components/escrow/escrow-content.tsx b/src/components/escrow/escrow-content.tsx index 1ce543c..0c119e3 100644 --- a/src/components/escrow/escrow-content.tsx +++ b/src/components/escrow/escrow-content.tsx @@ -6,8 +6,7 @@ import { TitleCard } from "@/components/escrow/title-card" import { TabView } from "@/components/escrow/tab-view" import { DesktopView } from "@/components/escrow/desktop-view" import { WelcomeState } from "@/components/escrow/welcome-state" -import error from "next/error" -import { useNetwork } from "@/contexts/NetworkContext"; // Add this line +import { useNetwork } from "@/contexts/NetworkContext"; interface EscrowContentProps { @@ -70,7 +69,7 @@ export const EscrowContent = ({ {/* No data state */} - + ) } diff --git a/src/components/escrow/welcome-state.tsx b/src/components/escrow/welcome-state.tsx index cf07b53..b8432fc 100644 --- a/src/components/escrow/welcome-state.tsx +++ b/src/components/escrow/welcome-state.tsx @@ -1,17 +1,21 @@ import { motion, AnimatePresence } from "framer-motion" import { Button } from "@/components/ui/button" -import { EXAMPLE_CONTRACT_ID } from "@/lib/escrow-constants" +import { EXAMPLE_CONTRACT_IDS } from "@/lib/escrow-constants" +import type { NetworkType } from "@/lib/network-config" interface WelcomeStateProps { showWelcome: boolean; handleUseExample?: () => void; + network?: NetworkType; } -export const WelcomeState = ({ +export const WelcomeState = ({ showWelcome, - handleUseExample + handleUseExample, + network = 'mainnet' }: WelcomeStateProps) => { const useExample = handleUseExample || (() => {}); + const exampleId = EXAMPLE_CONTRACT_IDS[network]; return ( @@ -55,7 +59,7 @@ export const WelcomeState = ({ className="text-sm text-blue-600 p-0 h-auto" onClick={useExample} > - {EXAMPLE_CONTRACT_ID} + {exampleId}

diff --git a/src/components/ui/theme-toggle.tsx b/src/components/ui/theme-toggle.tsx index 4f255a8..29dbafa 100644 --- a/src/components/ui/theme-toggle.tsx +++ b/src/components/ui/theme-toggle.tsx @@ -3,27 +3,34 @@ import { useEffect, useState } from "react"; export function ThemeToggle() { - const [isDark, setIsDark] = useState(() => { + const [isDark, setIsDark] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); 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; + if (stored) { + setIsDark(stored === "dark"); + } else { + setIsDark(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches); + } + } catch { + // ignore } - }); + }, []); useEffect(() => { + if (!mounted) return; const root = document.documentElement; if (isDark) root.classList.add("dark"); else root.classList.remove("dark"); try { localStorage.setItem("theme", isDark ? "dark" : "light"); - } catch { + } catch { // ignore } - }, [isDark]); + }, [isDark, mounted]); const toggle = () => setIsDark((v) => !v); diff --git a/src/hooks/useEscrowData.ts b/src/hooks/useEscrowData.ts index 5a48e3b..3f862b0 100644 --- a/src/hooks/useEscrowData.ts +++ b/src/hooks/useEscrowData.ts @@ -3,6 +3,17 @@ 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 { EXAMPLE_CONTRACT_IDS } from "@/lib/escrow-constants"; + +/** + * Basic validation for Stellar contract ID format + */ +function isValidContractId(contractId: string): boolean { + // Stellar contract IDs are 56 characters long and use base32 alphabet + if (contractId.length !== 56) return false; + const base32Regex = /^[A-Z2-7]+$/; + return base32Regex.test(contractId); +} /** * Loads raw escrow contract storage and maps it to OrganizedEscrowData for UI. @@ -19,10 +30,30 @@ export function useEscrowData(contractId: string, network: NetworkType, isMobile setLoading(true); setError(null); + // Validate contract ID format + if (!isValidContractId(contractId)) { + setRaw(null); + setOrganized(null); + setError(`Invalid contract ID format. Stellar contract IDs should be 56 characters long and contain only uppercase letters A-Z and digits 2-7.`); + setLoading(false); + return; + } + try { const data = await getLedgerKeyContractCode(contractId, network); - setRaw(data); - setOrganized(organizeEscrowData(data, contractId, isMobile)); + if (data.length === 0) { + setRaw(null); + setOrganized(null); + const isExampleContract = contractId === EXAMPLE_CONTRACT_IDS.testnet || contractId === EXAMPLE_CONTRACT_IDS.mainnet; + const baseMessage = `No ledger entry found for contract ID "${contractId}".`; + const suggestions = isExampleContract + ? `•This example contract may not be deployed on ${network}•Try switching to ${network === 'testnet' ? 'mainnet' : 'testnet'} or use a different contract ID•The contract was recently deployed and not yet indexed` + : `•The contract ID is invalid or doesn't exist on ${network}•The contract exists on a different network•The contract was recently deployed and not yet indexed`; + setError(`${baseMessage}${suggestions}`); + } else { + setRaw(data); + setOrganized(organizeEscrowData(data, contractId, isMobile)); + } } catch (e) { setRaw(null); setOrganized(null); diff --git a/src/hooks/useRecentEvents.ts b/src/hooks/useRecentEvents.ts index 645bede..a85f500 100644 --- a/src/hooks/useRecentEvents.ts +++ b/src/hooks/useRecentEvents.ts @@ -1,14 +1,14 @@ import { useState, useEffect, useCallback } from 'react'; -import { SorobanRpc } from '@stellar/stellar-sdk'; +import { rpc } from '@stellar/stellar-sdk'; import { getNetworkConfig, type NetworkType } from '@/lib/network-config'; export interface ContractEvent { id: string; type: string; ledger: number; - contractId: string; - topics: string[]; // base64 or decoded - value: string; // base64 or decoded + contractId?: string; + topics: string[]; // base64 + value: string; // base64 } export interface UseRecentEventsResult { @@ -34,7 +34,7 @@ export function useRecentEvents( try { const config = getNetworkConfig(network); - const server = new SorobanRpc.Server(config.rpcUrl); + const server = new rpc.Server(config.rpcUrl); // Get latest ledger const latestLedger = await server.getLatestLedger(); @@ -60,9 +60,9 @@ export function useRecentEvents( id: event.id, type: event.type, ledger: event.ledger, - contractId: event.contractId, - topics: event.topic.map((topic) => topic.toString('base64')), // keep as base64 for now - value: event.value.toString('base64'), // keep as base64 + contractId: event.contractId?.toString(), + topics: event.topic.map((topic) => topic.toXDR('base64')), + value: event.value.toXDR('base64'), })); // Sort by ledger descending (most recent first) diff --git a/src/lib/escrow-constants.ts b/src/lib/escrow-constants.ts index 64a1c65..928b535 100644 --- a/src/lib/escrow-constants.ts +++ b/src/lib/escrow-constants.ts @@ -86,4 +86,4 @@ export const EXAMPLE_CONTRACT_IDS = { mainnet: "CANVLF5SPV7LF6YOA2PFFPJQAFEUXEEE7SLKXRHUAMAN65EXFHBDLARP" }; -export const EXAMPLE_CONTRACT_ID = EXAMPLE_CONTRACT_IDS.testnet; +export const EXAMPLE_CONTRACT_ID = EXAMPLE_CONTRACT_IDS.mainnet; diff --git a/src/lib/network-config.ts b/src/lib/network-config.ts index d820a40..d567334 100644 --- a/src/lib/network-config.ts +++ b/src/lib/network-config.ts @@ -27,5 +27,5 @@ export function getNetworkConfig(network: NetworkType): NetworkConfig { } export function getDefaultNetwork(): NetworkType { - return 'testnet'; + return 'mainnet'; } \ No newline at end of file diff --git a/src/utils/ledgerkeycontract.ts b/src/utils/ledgerkeycontract.ts index bf990db..cbbf559 100644 --- a/src/utils/ledgerkeycontract.ts +++ b/src/utils/ledgerkeycontract.ts @@ -67,7 +67,7 @@ export async function getLedgerKeyContractCode( const entry = json.result.entries[0]; if (!entry) { - throw new Error("No ledger entry found for this contract ID"); + return []; } const contractData = entry?.dataJson?.contract_data?.val?.contract_instance; From d64cb4c7e49a745175685a12a03cac9b35c86e8b Mon Sep 17 00:00:00 2001 From: bitstarkbridge Date: Thu, 22 Jan 2026 16:38:49 +0000 Subject: [PATCH 3/4] Force push frontend codes --- src/lib/escrow-constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/escrow-constants.ts b/src/lib/escrow-constants.ts index 928b535..64a1c65 100644 --- a/src/lib/escrow-constants.ts +++ b/src/lib/escrow-constants.ts @@ -86,4 +86,4 @@ export const EXAMPLE_CONTRACT_IDS = { mainnet: "CANVLF5SPV7LF6YOA2PFFPJQAFEUXEEE7SLKXRHUAMAN65EXFHBDLARP" }; -export const EXAMPLE_CONTRACT_ID = EXAMPLE_CONTRACT_IDS.mainnet; +export const EXAMPLE_CONTRACT_ID = EXAMPLE_CONTRACT_IDS.testnet; From 338b2de9a1fc5ddf25d56516b6a4dd6f9b74eb2f Mon Sep 17 00:00:00 2001 From: bitstarkbridge Date: Thu, 22 Jan 2026 22:49:16 +0000 Subject: [PATCH 4/4] Force push backend code --- src/app/api/rpc/route.ts | 38 +++++++++ src/components/escrow/EscrowDetails.tsx | 25 +++++- .../escrow/TransactionDetailModal.tsx | 7 +- src/components/escrow/error-display.tsx | 44 ++++++++++- src/hooks/useEscrowData.ts | 8 +- src/hooks/useRecentEvents.ts | 77 ++++++++++++++----- src/lib/network-config.ts | 2 +- src/utils/ledgerkeycontract.ts | 7 +- src/utils/transactionFetcher.ts | 14 ++-- 9 files changed, 180 insertions(+), 42 deletions(-) create mode 100644 src/app/api/rpc/route.ts diff --git a/src/app/api/rpc/route.ts b/src/app/api/rpc/route.ts new file mode 100644 index 0000000..e250995 --- /dev/null +++ b/src/app/api/rpc/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getNetworkConfig } from '@/lib/network-config'; + +export async function POST(request: NextRequest) { + try { + const { network, ...body } = await request.json(); + + if (!network || !['testnet', 'mainnet'].includes(network)) { + return NextResponse.json({ error: 'Invalid network' }, { status: 400 }); + } + + const networkConfig = getNetworkConfig(network as 'testnet' | 'mainnet'); + + const response = await fetch(networkConfig.rpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + return NextResponse.json( + { error: `RPC error: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('RPC proxy error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/components/escrow/EscrowDetails.tsx b/src/components/escrow/EscrowDetails.tsx index d6d4bfa..77ae222 100644 --- a/src/components/escrow/EscrowDetails.tsx +++ b/src/components/escrow/EscrowDetails.tsx @@ -43,10 +43,22 @@ const EscrowDetailsClient: React.FC = ({ initialEscrowId, }) => { const router = useRouter(); - const { currentNetwork } = useNetwork(); + const { currentNetwork, setNetwork } = useNetwork(); // Input / responsive state const [contractId, setContractId] = useState(initialEscrowId); + + // Handle network switch, updating contract ID for examples + const handleSwitchNetwork = useCallback((network: 'testnet' | 'mainnet') => { + setNetwork(network); + // If current contract is an example, switch to the example for the new network + if (contractId === EXAMPLE_CONTRACT_IDS.testnet || contractId === EXAMPLE_CONTRACT_IDS.mainnet) { + const newContractId = EXAMPLE_CONTRACT_IDS[network]; + setContractId(newContractId); + // Update URL + router.replace(`/${newContractId}`); + } + }, [contractId, setNetwork, router, setContractId]); const isMobile = useIsMobile(); const [isSearchFocused, setIsSearchFocused] = useState(false); @@ -101,7 +113,7 @@ const isMobile = useIsMobile(); 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]); @@ -114,7 +126,7 @@ const isMobile = useIsMobile(); setTransactionLoading(false); } }, - [] + [currentNetwork] ); // Initial + network-change fetch (escrow + txs) @@ -261,7 +273,11 @@ useEffect(() => { )} {/* Error Display */} - + {/* Content Section (hidden when showing transactions as a page) */} {!showOnlyTransactions && ( @@ -410,6 +426,7 @@ useEffect(() => { isOpen={isModalOpen} onClose={handleModalClose} isMobile={isMobile} + network={currentNetwork} /> diff --git a/src/components/escrow/TransactionDetailModal.tsx b/src/components/escrow/TransactionDetailModal.tsx index 34cd8d2..e42547d 100644 --- a/src/components/escrow/TransactionDetailModal.tsx +++ b/src/components/escrow/TransactionDetailModal.tsx @@ -29,12 +29,14 @@ import { formatTransactionTime, truncateHash, } from "@/utils/transactionFetcher"; +import { 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/error-display.tsx b/src/components/escrow/error-display.tsx index b00ce76..34136ac 100644 --- a/src/components/escrow/error-display.tsx +++ b/src/components/escrow/error-display.tsx @@ -1,15 +1,29 @@ 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?: (network: 'testnet' | 'mainnet') => void + onRetry?: () => void } -export const ErrorDisplay = ({ error }: ErrorDisplayProps) => { +export const ErrorDisplay = ({ error, onSwitchNetwork, onRetry }: ErrorDisplayProps) => { if (!error) return null; const parts = error.split('•').map(part => part.trim()).filter(part => part); + // Check if error suggests switching networks + const switchSuggestion = parts.find(part => part.includes('Try switching to')); + let targetNetwork: 'testnet' | 'mainnet' | null = null; + if (switchSuggestion) { + if (switchSuggestion.includes('mainnet')) { + targetNetwork = 'mainnet'; + } else if (switchSuggestion.includes('testnet')) { + targetNetwork = 'testnet'; + } + } + return ( { >
-
+
{parts.length > 1 ? (

{parts[0]}

@@ -33,6 +47,30 @@ export const ErrorDisplay = ({ error }: ErrorDisplayProps) => { ) : (

{error}

)} + {/* Action buttons */} +
+ {targetNetwork && onSwitchNetwork && ( + + )} + {onRetry && ( + + )} +
diff --git a/src/hooks/useEscrowData.ts b/src/hooks/useEscrowData.ts index 3f862b0..30b2cc3 100644 --- a/src/hooks/useEscrowData.ts +++ b/src/hooks/useEscrowData.ts @@ -47,7 +47,7 @@ export function useEscrowData(contractId: string, network: NetworkType, isMobile const isExampleContract = contractId === EXAMPLE_CONTRACT_IDS.testnet || contractId === EXAMPLE_CONTRACT_IDS.mainnet; const baseMessage = `No ledger entry found for contract ID "${contractId}".`; const suggestions = isExampleContract - ? `•This example contract may not be deployed on ${network}•Try switching to ${network === 'testnet' ? 'mainnet' : 'testnet'} or use a different contract ID•The contract was recently deployed and not yet indexed` + ? `•This example contract may not be deployed on ${network}•Use a different contract ID•The contract was recently deployed and not yet indexed` : `•The contract ID is invalid or doesn't exist on ${network}•The contract exists on a different network•The contract was recently deployed and not yet indexed`; setError(`${baseMessage}${suggestions}`); } else { @@ -57,7 +57,11 @@ export function useEscrowData(contractId: string, network: NetworkType, isMobile } catch (e) { setRaw(null); setOrganized(null); - setError(e instanceof Error ? e.message : "Failed to fetch escrow data"); + const errorMessage = e instanceof Error ? e.message : "Failed to fetch escrow data"; + const userFriendlyMessage = errorMessage === "Failed to fetch" + ? "Network error: Unable to connect to Stellar RPC. Please check your internet connection or try again later." + : errorMessage; + setError(userFriendlyMessage); } finally { setLoading(false); } diff --git a/src/hooks/useRecentEvents.ts b/src/hooks/useRecentEvents.ts index a85f500..adc25fc 100644 --- a/src/hooks/useRecentEvents.ts +++ b/src/hooks/useRecentEvents.ts @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { rpc } from '@stellar/stellar-sdk'; -import { getNetworkConfig, type NetworkType } from '@/lib/network-config'; +import { type NetworkType } from '@/lib/network-config'; export interface ContractEvent { id: string; @@ -33,36 +32,76 @@ export function useRecentEvents( setError(null); try { - const config = getNetworkConfig(network); - const server = new rpc.Server(config.rpcUrl); - // Get latest ledger - const latestLedger = await server.getLatestLedger(); - const endLedger = latestLedger.sequence; + const latestLedgerResponse = await fetch('/api/rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + network, + jsonrpc: '2.0', + id: 1, + method: 'getLatestLedger', + }), + }); + + if (!latestLedgerResponse.ok) { + throw new Error('Failed to get latest ledger'); + } + + const latestLedgerData = await latestLedgerResponse.json(); + const endLedger = latestLedgerData.result.sequence; // Approximate 7 days: ~5 seconds per ledger, 7*24*3600/5 ≈ 12096 const sevenDaysLedgers = Math.floor((7 * 24 * 3600) / 5); const startLedger = Math.max(1, endLedger - sevenDaysLedgers); - const response = await server.getEvents({ - startLedger, - filters: [ - { - contractIds: [contractId], - type: 'contract', + // Get events + const eventsResponse = await fetch('/api/rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + network, + jsonrpc: '2.0', + id: 2, + method: 'getEvents', + params: { + startLedger, + filters: [ + { + contractIds: [contractId], + type: 'contract', + }, + ], + limit: 100, }, - ], - limit: 100, // reasonable limit + }), }); + if (!eventsResponse.ok) { + throw new Error('Failed to get events'); + } + + const eventsData = await eventsResponse.json(); + + if (eventsData.error) { + throw new Error(eventsData.error.message || 'Failed to fetch events'); + } + // Map to our interface - const mappedEvents: ContractEvent[] = response.events.map((event) => ({ + const mappedEvents: ContractEvent[] = (eventsData.result?.events || []).map((event: { + id: string; + type: string; + ledger: number; + contractId?: string; + topic: string[]; + value: string; + }) => ({ id: event.id, type: event.type, ledger: event.ledger, - contractId: event.contractId?.toString(), - topics: event.topic.map((topic) => topic.toXDR('base64')), - value: event.value.toXDR('base64'), + contractId: event.contractId, + topics: event.topic, + value: event.value, })); // Sort by ledger descending (most recent first) diff --git a/src/lib/network-config.ts b/src/lib/network-config.ts index d567334..964dbe4 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 cbbf559..479557d 100644 --- a/src/utils/ledgerkeycontract.ts +++ b/src/utils/ledgerkeycontract.ts @@ -1,5 +1,5 @@ import { Contract } from "@stellar/stellar-sdk"; -import { NetworkType, getNetworkConfig } from "@/lib/network-config"; +import { NetworkType } from "@/lib/network-config"; // Define types for the escrow data map interface EscrowKey { @@ -46,13 +46,12 @@ export async function getLedgerKeyContractCode( }, }; - const networkConfig = getNetworkConfig(network); - const res = await fetch(networkConfig.rpcUrl, { + const res = await fetch('/api/rpc', { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(requestBody), + body: JSON.stringify({ network, ...requestBody }), }); if (!res.ok) { diff --git a/src/utils/transactionFetcher.ts b/src/utils/transactionFetcher.ts index bb720a5..078ef7b 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 { NetworkType } from "@/lib/network-config"; // Types for transaction data export interface TransactionMetadata { @@ -38,14 +39,13 @@ 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 * Gracefully handles retention-related errors */ export async function fetchTransactions( contractId: string, + network: NetworkType, options: FetchTransactionsOptions = {} ): Promise { try { @@ -72,12 +72,12 @@ export async function fetchTransactions( } }; - const response = await fetch(SOROBAN_RPC_URL, { + const response = await fetch('/api/rpc', { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(requestBody), + body: JSON.stringify({ network, ...requestBody }), }); if (!response.ok) { @@ -138,7 +138,7 @@ export async function fetchTransactions( * 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 requestBody = { jsonrpc: "2.0", @@ -149,12 +149,12 @@ export async function fetchTransactionDetails(txHash: string): Promise