diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx new file mode 100644 index 0000000..e8d1ef0 --- /dev/null +++ b/src/app/transactions/page.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Navbar } from "@/components/Navbar"; +import { useWallet } from "@/app/providers"; +import { + fetchAccountTransactions, + ParsedTransaction, + Network, +} from "@/lib/horizon"; + +const OPERATION_ICONS: Record = { + payment: "πŸ’Έ", + invoke_host_function: "πŸ“œ", + create_account: "πŸ†•", + path_payment_strict_send: "πŸ”€", + path_payment_strict_receive: "πŸ”€", + change_trust: "πŸ”—", + account_merge: "πŸ”„", +}; + +function operationIcon(type: string): string { + return OPERATION_ICONS[type] ?? "⚑"; +} + +function formatDate(date: Date): string { + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function formatAmount(amount?: string, assetCode?: string): string | null { + if (!amount) return null; + const num = parseFloat(amount); + return `${num.toLocaleString(undefined, { maximumFractionDigits: 7 })} ${assetCode ?? "XLM"}`; +} + +function shortenHash(hash: string): string { + return `${hash.slice(0, 8)}…${hash.slice(-6)}`; +} + +function TransactionRow({ tx }: { tx: ParsedTransaction }) { + return ( + + +
+ + {tx.label} +
+ + + {formatDate(tx.createdAt)} + + + {formatAmount(tx.amount, tx.assetCode) ?? ( + β€” + )} + + + + {shortenHash(tx.hash)} + + + + ); +} + +function EmptyState({ message }: { message: string }) { + return ( +
+

πŸ“­

+

{message}

+
+ ); +} + +export default function TransactionsPage() { + const { address, isConnected } = useWallet(); + const [network, setNetwork] = useState("testnet"); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [cursors, setCursors] = useState([]); // stack of prev cursors + const [nextCursor, setNextCursor] = useState(null); + + const loadTransactions = useCallback( + async (cursor?: string) => { + if (!address) return; + setLoading(true); + setError(null); + try { + const result = await fetchAccountTransactions(address, network, cursor); + setTransactions(result.records); + setNextCursor(result.nextCursor); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load transactions" + ); + } finally { + setLoading(false); + } + }, + [address, network] + ); + + // Reload when address or network changes + useEffect(() => { + setCursors([]); + setNextCursor(null); + setTransactions([]); + if (address) loadTransactions(); + }, [address, network]); // eslint-disable-line react-hooks/exhaustive-deps + + function handleNextPage() { + if (!nextCursor) return; + // Push current "first" marker onto stack so we can go back + setCursors((prev) => [...prev, nextCursor]); + loadTransactions(nextCursor); + } + + function handlePrevPage() { + if (cursors.length === 0) return; + const newStack = cursors.slice(0, -1); + const prevCursor = newStack[newStack.length - 1]; + setCursors(newStack); + loadTransactions(prevCursor); + } + + const isFirstPage = cursors.length === 0; + const currentPage = cursors.length + 1; + + return ( + <> + +
+ {/* Header */} +
+
+

+ Transaction History +

+

+ On-chain operations fetched from Stellar Horizon +

+
+ + {/* Network selector */} +
+ + +
+
+ + {/* Not connected */} + {!isConnected && ( + + )} + + {/* Loading */} + {isConnected && loading && ( +
+ + + + + Loading transactions… +
+ )} + + {/* Error */} + {isConnected && !loading && error && ( +
+

Error loading transactions

+

{error}

+ +
+ )} + + {/* Empty */} + {isConnected && !loading && !error && transactions.length === 0 && ( + + )} + + {/* Table */} + {isConnected && !loading && !error && transactions.length > 0 && ( + <> +
+ + + + + + + + + + + {transactions.map((tx) => ( + + ))} + +
+ Type + + Date + + Amount + + Transaction +
+
+ + {/* Pagination */} +
+

Page {currentPage}

+
+ + +
+
+ + )} +
+ + ); +} 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..2a22aec --- /dev/null +++ b/src/lib/horizon.ts @@ -0,0 +1,262 @@ +/** + * Stellar Horizon API client for fetching on-chain transaction history. + * Supports both Testnet and Mainnet with pagination and caching. + */ + +const HORIZON_URLS = { + testnet: "https://horizon-testnet.stellar.org", + mainnet: "https://horizon.stellar.org", +} as const; + +export type Network = keyof typeof HORIZON_URLS; + +export type OperationType = + | "payment" + | "invoke_host_function" + | "create_account" + | "path_payment_strict_send" + | "path_payment_strict_receive" + | "change_trust" + | "account_merge" + | string; + +export interface HorizonOperation { + id: string; + type: OperationType; + type_i: number; + created_at: string; + transaction_hash: string; + source_account: string; + // payment fields + amount?: string; + asset_type?: string; + asset_code?: string; + asset_issuer?: string; + from?: string; + to?: string; + // invoke_host_function fields + function?: string; +} + +export interface HorizonTransaction { + id: string; + hash: string; + created_at: string; + source_account: string; + fee_charged: string; + operation_count: number; + successful: boolean; + ledger: number; +} + +export interface ParsedTransaction { + id: string; + hash: string; + type: string; + label: string; + createdAt: Date; + sourceAccount: string; + amount?: string; + assetCode?: string; + from?: string; + to?: string; + successful: boolean; + explorerUrl: string; +} + +export interface PaginatedTransactions { + records: ParsedTransaction[]; + nextCursor: string | null; + prevCursor: string | null; +} + +const PAGE_SIZE = 20; + +// Simple in-memory cache: key -> { data, timestamp } +const cache = new Map(); +const CACHE_TTL_MS = 30_000; // 30 seconds + +function getCachedOrNull(key: string): PaginatedTransactions | 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; +} + +function setCache(key: string, data: PaginatedTransactions) { + cache.set(key, { data, timestamp: Date.now() }); +} + +function operationLabel(op: HorizonOperation): string { + switch (op.type) { + case "payment": + return "Payment"; + case "invoke_host_function": + return "Contract Call"; + case "create_account": + return "Create Account"; + case "path_payment_strict_send": + case "path_payment_strict_receive": + return "Path Payment"; + case "change_trust": + return "Change Trust"; + case "account_merge": + return "Account Merge"; + default: + return op.type + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + } +} + +function explorerUrl(hash: string, network: Network): string { + const base = + network === "mainnet" + ? "https://stellar.expert/explorer/public/tx" + : "https://stellar.expert/explorer/testnet/tx"; + return `${base}/${hash}`; +} + +function parseOperation( + op: HorizonOperation, + network: Network +): ParsedTransaction { + return { + id: op.id, + hash: op.transaction_hash, + type: op.type, + label: operationLabel(op), + createdAt: new Date(op.created_at), + sourceAccount: op.source_account, + amount: op.amount, + assetCode: op.asset_type === "native" ? "XLM" : op.asset_code, + from: op.from, + to: op.to, + successful: true, + explorerUrl: explorerUrl(op.transaction_hash, network), + }; +} + +/** + * Fetch operations for a given Stellar account with cursor-based pagination. + * Results are cached for CACHE_TTL_MS. + */ +export async function fetchAccountTransactions( + accountId: string, + network: Network = "testnet", + cursor?: string +): Promise { + const cacheKey = `${network}:${accountId}:${cursor ?? "first"}`; + const cached = getCachedOrNull(cacheKey); + if (cached) return cached; + + const base = HORIZON_URLS[network]; + const params = new URLSearchParams({ + limit: String(PAGE_SIZE), + order: "desc", + }); + if (cursor) params.set("cursor", cursor); + + const url = `${base}/accounts/${accountId}/operations?${params}`; + const res = await fetch(url, { + headers: { Accept: "application/json" }, + }); + + if (!res.ok) { + if (res.status === 404) { + // Account not found on this network β€” return empty list + return { records: [], nextCursor: null, prevCursor: null }; + } + throw new Error(`Horizon API error: ${res.status} ${res.statusText}`); + } + + const json = await res.json(); + const records = (json._embedded?.records ?? []).map((op: HorizonOperation) => + parseOperation(op, network) + ); + + // Extract cursors from HAL links + const nextLink: string | undefined = json._links?.next?.href; + const prevLink: string | undefined = json._links?.prev?.href; + + function extractCursor(link?: string): string | null { + if (!link) return null; + try { + const u = new URL(link); + return u.searchParams.get("cursor"); + } catch { + return null; + } + } + + const result: PaginatedTransactions = { + records, + nextCursor: extractCursor(nextLink), + prevCursor: extractCursor(prevLink), + }; + + setCache(cacheKey, result); + return result; +} + +/** + * Fetch recent transactions for a Soroban contract address. + * Uses the contract's ledger entries endpoint to find relevant operations. + */ +export async function fetchContractTransactions( + contractId: string, + network: Network = "testnet", + cursor?: string +): Promise { + const cacheKey = `contract:${network}:${contractId}:${cursor ?? "first"}`; + const cached = getCachedOrNull(cacheKey); + if (cached) return cached; + + const base = HORIZON_URLS[network]; + const params = new URLSearchParams({ + limit: String(PAGE_SIZE), + order: "desc", + }); + if (cursor) params.set("cursor", cursor); + + const url = `${base}/accounts/${contractId}/operations?${params}`; + const res = await fetch(url, { + headers: { Accept: "application/json" }, + }); + + if (!res.ok) { + // Contracts may not always be queryable as accounts; return empty gracefully + return { records: [], nextCursor: null, prevCursor: null }; + } + + const json = await res.json(); + const records = (json._embedded?.records ?? []).map((op: HorizonOperation) => + parseOperation(op, network) + ); + + const nextLink: string | undefined = json._links?.next?.href; + const prevLink: string | undefined = json._links?.prev?.href; + + function extractCursor(link?: string): string | null { + if (!link) return null; + try { + const u = new URL(link); + return u.searchParams.get("cursor"); + } catch { + return null; + } + } + + const result: PaginatedTransactions = { + records, + nextCursor: extractCursor(nextLink), + prevCursor: extractCursor(prevLink), + }; + + setCache(cacheKey, result); + return result; +}