From 80ae4354b7cbc4091380824f9973c90c32a8e06b Mon Sep 17 00:00:00 2001 From: Auny Date: Tue, 12 May 2026 01:54:14 -0400 Subject: [PATCH] feat(transactions): wallet transaction history via Horizon (#63) Adds a new /transactions page that fetches on-chain operations for the SoroSave contract from the Stellar Horizon API. Implements: - src/lib/horizon.ts: fetch()-based Horizon client with typed transaction and operation models, cursor-based pagination, and a 30s in-memory response cache to limit API hits. - src/app/transactions/page.tsx: paginated, filterable transaction list with operation-type labels, success/failure status, relative times, and deep links to Horizon for each tx. - Navbar: link to /transactions. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/transactions/page.tsx | 238 ++++++++++++++++++++++++++++++++ src/components/Navbar.tsx | 6 + src/lib/horizon.ts | 252 ++++++++++++++++++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 src/app/transactions/page.tsx create mode 100644 src/lib/horizon.ts diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx new file mode 100644 index 0000000..5455e2e --- /dev/null +++ b/src/app/transactions/page.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Navbar } from "@/components/Navbar"; +import { CONTRACT_ID } from "@/lib/sorosave"; +import { + HORIZON_URL, + HorizonOperation, + fetchOperationsForAccount, + formatOperationType, + shortenAddress, + summarizeOperation, +} from "@/lib/horizon"; + +const PAGE_SIZE = 20; + +type LoadDirection = "initial" | "next" | "prev"; + +export default function TransactionsPage() { + const [operations, setOperations] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [prevCursor, setPrevCursor] = useState(null); + const [cursorStack, setCursorStack] = useState>([undefined]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [contractId, setContractId] = useState(CONTRACT_ID); + const [inputValue, setInputValue] = useState(CONTRACT_ID); + + const load = useCallback( + async (account: string, cursor: string | undefined, direction: LoadDirection) => { + if (!account) { + setOperations([]); + setNextCursor(null); + setPrevCursor(null); + setError( + "No contract address configured. Set NEXT_PUBLIC_CONTRACT_ID or enter one above." + ); + return; + } + + setLoading(true); + setError(null); + try { + const page = await fetchOperationsForAccount(account, { + limit: PAGE_SIZE, + cursor, + order: "desc", + }); + setOperations(page.records); + setNextCursor(page.nextCursor); + setPrevCursor(page.prevCursor); + + if (direction === "initial") { + setCursorStack([undefined]); + } else if (direction === "next") { + setCursorStack((stack) => [...stack, cursor]); + } else if (direction === "prev") { + setCursorStack((stack) => (stack.length > 1 ? stack.slice(0, -1) : stack)); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + setOperations([]); + setNextCursor(null); + setPrevCursor(null); + } finally { + setLoading(false); + } + }, + [] + ); + + useEffect(() => { + load(contractId, undefined, "initial"); + }, [contractId, load]); + + const onSubmitAddress = (e: React.FormEvent) => { + e.preventDefault(); + setContractId(inputValue.trim()); + }; + + const canGoBack = cursorStack.length > 1 && !loading; + const canGoNext = !!nextCursor && !loading && operations.length > 0; + + return ( + <> + +
+
+

Transaction History

+

+ On-chain operations involving the SoroSave contract, fetched from{" "} + + Stellar Horizon + + . +

+
+ +
+ + setInputValue(e.target.value)} + placeholder="Contract or account address (C… or G…)" + className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono" + /> + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + + + + + + + {loading && operations.length === 0 && ( + + + + )} + {!loading && operations.length === 0 && !error && ( + + + + )} + {operations.map((op) => ( + + + + + + + + ))} + +
TypeSummaryWhenStatusTx
+ Loading transactions… +
+ No operations found for this address. +
+ {formatOperationType(op.type)} + {summarizeOperation(op)} + {formatRelative(op.created_at)} + + {op.transaction_successful ? ( + Success + ) : ( + Failed + )} + + + {shortenAddress(op.transaction_hash)} + +
+
+ +
+ + + Page {cursorStack.length} + {loading ? " · loading…" : ""} + + +
+
+ + ); +} + +function formatRelative(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return iso; + const diffSec = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.round(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return new Date(iso).toLocaleDateString(); +} 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..4c0bf9a --- /dev/null +++ b/src/lib/horizon.ts @@ -0,0 +1,252 @@ +/** + * Stellar Horizon API client for fetching on-chain transaction history. + * + * Horizon exposes operations and transactions for any account address, including + * Soroban contract addresses (C…). We hit the public endpoints with fetch() so + * we don't pull in the full @stellar/stellar-sdk just for read-only queries. + * + * Docs: https://developers.stellar.org/api/horizon + */ + +const DEFAULT_HORIZON_URL = + process.env.NEXT_PUBLIC_HORIZON_URL || "https://horizon-testnet.stellar.org"; + +export const HORIZON_URL = DEFAULT_HORIZON_URL; + +export type HorizonOrder = "asc" | "desc"; + +export interface HorizonPageOptions { + limit?: number; + cursor?: string; + order?: HorizonOrder; +} + +export interface HorizonTransaction { + id: string; + paging_token: string; + successful: boolean; + hash: string; + ledger: number; + created_at: string; + source_account: string; + fee_charged: string; + operation_count: number; + memo?: string; + memo_type?: string; +} + +/** + * Subset of operation fields we surface in the UI. Horizon returns many more + * fields; we keep this loose so unknown operation types still render. + */ +export interface HorizonOperation { + id: string; + paging_token: string; + transaction_hash: string; + transaction_successful: boolean; + source_account: string; + type: string; + type_i: number; + created_at: string; + + // payment / path_payment_* + asset_type?: string; + asset_code?: string; + asset_issuer?: string; + amount?: string; + from?: string; + to?: string; + + // invoke_host_function (Soroban) + function?: string; + parameters?: Array<{ type: string; value: string }>; + address?: string; + salt?: string; +} + +interface HorizonCollection { + _links: { + self: { href: string }; + next: { href: string }; + prev: { href: string }; + }; + _embedded: { + records: T[]; + }; +} + +export interface HorizonPage { + records: T[]; + nextCursor: string | null; + prevCursor: string | null; +} + +interface CacheEntry { + value: T; + expiresAt: number; +} + +const CACHE_TTL_MS = 30_000; +const cache = new Map>(); + +function getCached(key: string): T | null { + const entry = cache.get(key); + if (!entry) return null; + if (entry.expiresAt < Date.now()) { + cache.delete(key); + return null; + } + return entry.value as T; +} + +function setCached(key: string, value: T): void { + cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); +} + +/** Clears the in-memory Horizon response cache. Mostly useful for tests. */ +export function clearHorizonCache(): void { + cache.clear(); +} + +function buildUrl(path: string, params: Record): string { + const url = new URL(path, HORIZON_URL); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === "") continue; + url.searchParams.set(key, String(value)); + } + return url.toString(); +} + +function extractCursor(href: string | undefined): string | null { + if (!href) return null; + try { + const url = new URL(href); + return url.searchParams.get("cursor"); + } catch { + return null; + } +} + +async function horizonGet(url: string): Promise> { + const cached = getCached>(url); + if (cached) return cached; + + const res = await fetch(url, { + headers: { Accept: "application/json" }, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `Horizon request failed (${res.status} ${res.statusText}): ${body.slice(0, 200)}` + ); + } + + const data = (await res.json()) as HorizonCollection; + setCached(url, data); + return data; +} + +function toPage(collection: HorizonCollection): HorizonPage { + return { + records: collection._embedded.records, + nextCursor: extractCursor(collection._links.next?.href), + prevCursor: extractCursor(collection._links.prev?.href), + }; +} + +/** + * Fetches transactions involving the given account or contract address. + * Works for both classic G… accounts and C… contract addresses on Horizon. + */ +export async function fetchTransactionsForAccount( + account: string, + options: HorizonPageOptions = {} +): Promise> { + if (!account) { + return { records: [], nextCursor: null, prevCursor: null }; + } + const url = buildUrl(`/accounts/${account}/transactions`, { + limit: options.limit ?? 20, + cursor: options.cursor, + order: options.order ?? "desc", + }); + return toPage(await horizonGet(url)); +} + +/** + * Fetches operations involving the given account or contract address. Returns + * the lower-level operation records (payments, invocations, etc.) which is + * usually what we want to show in a transaction-history feed. + */ +export async function fetchOperationsForAccount( + account: string, + options: HorizonPageOptions = {} +): Promise> { + if (!account) { + return { records: [], nextCursor: null, prevCursor: null }; + } + const url = buildUrl(`/accounts/${account}/operations`, { + limit: options.limit ?? 20, + cursor: options.cursor, + order: options.order ?? "desc", + include_failed: "true", + }); + return toPage(await horizonGet(url)); +} + +/** Human-friendly label for a Horizon operation type. */ +export function formatOperationType(type: string): string { + switch (type) { + case "payment": + return "Payment"; + case "create_account": + return "Account Created"; + case "path_payment_strict_send": + case "path_payment_strict_receive": + return "Path Payment"; + case "change_trust": + return "Trustline Change"; + case "manage_sell_offer": + case "manage_buy_offer": + case "create_passive_sell_offer": + return "Offer"; + case "invoke_host_function": + return "Contract Invocation"; + case "extend_footprint_ttl": + return "TTL Extension"; + case "restore_footprint": + return "Restore Footprint"; + default: + return type + .split("_") + .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)) + .join(" "); + } +} + +/** Short summary describing what an operation did, for list rows. */ +export function summarizeOperation(op: HorizonOperation): string { + switch (op.type) { + case "payment": { + const asset = op.asset_type === "native" ? "XLM" : op.asset_code || "asset"; + return `${op.amount ?? "?"} ${asset} from ${shortenAddress(op.from)} to ${shortenAddress(op.to)}`; + } + case "create_account": + return `Funded ${shortenAddress(op.to ?? op.address)} with ${op.amount ?? "?"} XLM`; + case "invoke_host_function": + return op.function + ? `${op.function}${op.address ? ` on ${shortenAddress(op.address)}` : ""}` + : "Contract invocation"; + default: + return formatOperationType(op.type); + } +} + +function shortenAddress(address: string | undefined | null): string { + if (!address) return "—"; + if (address.length <= 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +export { shortenAddress };