diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx new file mode 100644 index 0000000..f2c439c --- /dev/null +++ b/src/app/transactions/page.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Navbar } from "@/components/Navbar"; +import { + fetchTransactions, + ParsedTransaction, + TransactionPage, +} from "@/lib/horizon"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function truncateAddress(addr: string, chars = 6): string { + if (addr.length <= chars * 2 + 3) return addr; + return `${addr.slice(0, chars)}...${addr.slice(-chars)}`; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(); +} + +function getOperationLabel(type: string): string { + switch (type) { + case "payment": + return "Payment"; + case "create_account": + return "Create Account"; + case "invoke_host_function": + return "Contract Invoke"; + case "manage_sell_offer": + return "Manage Offer"; + case "change_trust": + return "Change Trust"; + default: + return type.replace(/_/g, " "); + } +} + +function getOperationColor(type: string): string { + switch (type) { + case "payment": + return "bg-green-100 text-green-800"; + case "invoke_host_function": + return "bg-purple-100 text-purple-800"; + case "create_account": + return "bg-blue-100 text-blue-800"; + default: + return "bg-gray-100 text-gray-800"; + } +} + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +function OperationBadge({ type }: { type: string }) { + return ( + + {getOperationLabel(type)} + + ); +} + +function TransactionRow({ tx }: { tx: ParsedTransaction }) { + return ( +
+
+ {/* Top row */} +
+
+
+ + {truncateAddress(tx.hash, 8)} + +
+ {formatDate(tx.createdAt)} +
+ + {/* Meta */} +
+ + Source:{" "} + {truncateAddress(tx.sourceAccount)} + + Ledger #{tx.ledger} + {tx.memo && Memo: {tx.memo}} +
+ + {/* Operations */} + {tx.operations.length > 0 && ( +
+ {tx.operations.map((op) => ( +
+ + {op.type === "payment" && ( + + {op.amount}{" "} + {op.asset_type === "native" ? "XLM" : op.asset_code} + {" → "} + + {truncateAddress(op.to ?? "", 4)} + + + )} +
+ ))} +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export default function TransactionsPage() { + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [cursor, setCursor] = useState(null); + const [cursorStack, setCursorStack] = useState<(string | null)[]>([null]); + + const loadPage = useCallback(async (cursorVal: string | null) => { + setLoading(true); + setError(null); + try { + const data = await fetchTransactions(cursorVal, 10, "desc"); + setPage(data); + setCursor(cursorVal); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch transactions"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadPage(null); + }, [loadPage]); + + const handleNext = () => { + if (page?.nextCursor) { + setCursorStack((prev) => [...prev, page.nextCursor]); + loadPage(page.nextCursor); + } + }; + + const handlePrev = () => { + if (cursorStack.length > 1) { + const stack = [...cursorStack]; + stack.pop(); + const prevCursor = stack[stack.length - 1]; + setCursorStack(stack); + loadPage(prevCursor); + } + }; + + const handleRefresh = () => { + setCursorStack([null]); + loadPage(null); + }; + + return ( + <> + +
+ {/* Header */} +
+
+

+ Transaction History +

+

+ On-chain transactions from Stellar Horizon +

+
+ +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Loading skeleton */} + {loading && !page && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Transaction list */} + {page && page.transactions.length > 0 && ( +
+ {page.transactions.map((tx) => ( + + ))} +
+ )} + + {/* Empty state */} + {page && page.transactions.length === 0 && ( +
+ + + +

+ No transactions found +

+

+ There are no transactions for this account yet. +

+
+ )} + + {/* Pagination */} + {page && page.transactions.length > 0 && ( +
+ + + + Page {cursorStack.length} + + + +
+ )} +
+ + ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 2d673aa..9d6d67c 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -25,6 +25,12 @@ export function Navbar() { > Create Group + + Transactions +
diff --git a/src/lib/horizon.ts b/src/lib/horizon.ts new file mode 100644 index 0000000..c3a747c --- /dev/null +++ b/src/lib/horizon.ts @@ -0,0 +1,217 @@ +/** + * Stellar Horizon API client for fetching transaction history. + * Provides caching and pagination support. + */ + +const HORIZON_URL = + process.env.NEXT_PUBLIC_HORIZON_URL || + "https://horizon-testnet.stellar.org"; + +const CONTRACT_ID = process.env.NEXT_PUBLIC_CONTRACT_ID || ""; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface HorizonTransaction { + id: string; + hash: string; + ledger: number; + created_at: string; + source_account: string; + operation_count: number; + memo_type: string; + memo?: string; + successful: boolean; + paging_token: string; +} + +export interface TransactionOperation { + id: string; + type: string; + type_i: number; + source_account: string; + transaction_hash: string; + created_at: string; + from?: string; + to?: string; + amount?: string; + asset_type?: string; + asset_code?: string; + asset_issuer?: string; + contract_id?: string; + function?: string; +} + +export interface ParsedTransaction { + id: string; + hash: string; + ledger: number; + createdAt: string; + sourceAccount: string; + successful: boolean; + operationCount: number; + operations: TransactionOperation[]; + memo?: string; +} + +export interface TransactionPage { + transactions: ParsedTransaction[]; + nextCursor: string | null; + prevCursor: string | null; +} + +// --------------------------------------------------------------------------- +// Simple in-memory cache +// --------------------------------------------------------------------------- + +interface CacheEntry { + data: T; + timestamp: number; +} + +const cache = new Map>(); +const CACHE_TTL_MS = 30_000; + +function getCached(key: string): T | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() - entry.timestamp > CACHE_TTL_MS) { + cache.delete(key); + return null; + } + return entry.data as T; +} + +function setCache(key: string, data: T): void { + cache.set(key, { data, timestamp: Date.now() }); +} + +export function clearHorizonCache(): void { + cache.clear(); +} + +// --------------------------------------------------------------------------- +// Horizon API helpers +// --------------------------------------------------------------------------- + +async function fetchHorizon(path: string): Promise { + const url = `${HORIZON_URL}${path}`; + const res = await fetch(url); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Horizon error ${res.status}: ${text}`); + } + return res.json() as Promise; +} + +interface HorizonTransactionResponse { + _embedded: { + records: HorizonTransaction[]; + }; + _links: { + next: { href: string }; + prev: { href: string }; + }; +} + +interface HorizonOperationsResponse { + _embedded: { + records: TransactionOperation[]; + }; +} + +async function fetchTransactionOperations( + txHash: string +): Promise { + const data = await fetchHorizon( + `/transactions/${txHash}/operations?limit=50` + ); + return data._embedded.records; +} + +async function enrichTransactions( + records: HorizonTransaction[] +): Promise { + const parsed = await Promise.all( + records.map(async (tx) => { + let operations: TransactionOperation[] = []; + try { + operations = await fetchTransactionOperations(tx.hash); + } catch { + // If operations fetch fails, continue without them + } + return { + id: tx.id, + hash: tx.hash, + ledger: tx.ledger, + createdAt: tx.created_at, + sourceAccount: tx.source_account, + successful: tx.successful, + operationCount: tx.operation_count, + operations, + memo: tx.memo, + }; + }) + ); + return parsed; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function fetchTransactions( + cursor: string | null = null, + limit = 10, + order: "asc" | "desc" = "desc" +): Promise { + if (!CONTRACT_ID) { + throw new Error( + "NEXT_PUBLIC_CONTRACT_ID is not configured. Set it in your .env file." + ); + } + + const cacheKey = `tx:${CONTRACT_ID}:${cursor ?? "latest"}:${limit}:${order}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const params = new URLSearchParams({ + limit: String(Math.min(limit, 200)), + order, + include_failed: "true", + }); + if (cursor) { + params.set("cursor", cursor); + } + + const data = await fetchHorizon( + `/accounts/${CONTRACT_ID}/transactions?${params}` + ); + + const transactions = await enrichTransactions(data._embedded.records); + + const nextHref = data._links.next?.href ?? null; + const prevHref = data._links.prev?.href ?? null; + const nextCursor = nextHref + ? new URL(nextHref, HORIZON_URL).searchParams.get("cursor") + : null; + const prevCursor = prevHref + ? new URL(prevHref, HORIZON_URL).searchParams.get("cursor") + : null; + + const page: TransactionPage = { + transactions, + nextCursor, + prevCursor, + }; + + setCache(cacheKey, page); + return page; +} + +export async function fetchRecentTransactions( + limit = 10 +): Promise { + return fetchTransactions(null, limit, "desc"); +}