From 5c9ce9d9ece3e2f9718633c6dc091fe6abe8e96e Mon Sep 17 00:00:00 2001 From: adenueltech Date: Sat, 17 Oct 2026 20:59:53 +0100 Subject: [PATCH 1/2] feat: transaction history with filtering by wallet (#887) - Fetch payment history from Horizon (/accounts/{id}/payments, joined with transactions) with cursor-based loading of older pages - Normalise payments, path payments, account creation/merge and contract calls - New /transactions page: select connected, saved or manually added wallets; filter by date range, type, direction and asset; free-text search over hash, addresses, memo and amount; details panel with explorer link; pagination - Export filtered results as CSV (escaped, formula-injection safe) or JSON - Navigation links and unit tests Closes #887 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/transactions/page.tsx | 338 +++++++++++++++++++ src/components/Navbar.tsx | 1 + src/components/layout/MobileSidebar.tsx | 1 + src/lib/__tests__/transactionHistory.test.ts | 132 ++++++++ src/lib/transactionHistory.ts | 248 ++++++++++++++ 5 files changed, 720 insertions(+) create mode 100644 src/app/transactions/page.tsx create mode 100644 src/lib/__tests__/transactionHistory.test.ts create mode 100644 src/lib/transactionHistory.ts diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx new file mode 100644 index 00000000..d62587b0 --- /dev/null +++ b/src/app/transactions/page.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useWalletContext } from "@/contexts/WalletContext"; +import { getAddressBook } from "@/lib/addressBook"; +import { useDebounce } from "@/hooks/useDebounce"; +import { + TX_TYPE_LABELS, + downloadFile, + explorerTxLink, + fetchTransactionHistory, + filterTransactions, + mergeHistories, + paginate, + transactionsToCSV, + transactionsToJSON, + type TxDirection, + type TxRecord, + type TxType, +} from "@/lib/transactionHistory"; + +const PAGE_SIZE = 20; +const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; + +interface WalletOption { + address: string; + label: string; +} + +const short = (a: string) => (a ? `${a.slice(0, 4)}…${a.slice(-4)}` : "—"); + +/** + * Transaction history for the connected wallet and any saved/added wallets, + * with filtering by wallet, date range, type, direction and asset, free-text + * search, a details panel, pagination and CSV/JSON export. + */ +export default function TransactionsPage() { + const { address } = useWalletContext(); + const [extraWallets, setExtraWallets] = useState([]); + const [newWallet, setNewWallet] = useState(""); + const [selectedWallets, setSelectedWallets] = useState([]); + + const [records, setRecords] = useState([]); + const [cursors, setCursors] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [types, setTypes] = useState([]); + const [direction, setDirection] = useState("all"); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); + const [asset, setAsset] = useState(""); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 250); + const [page, setPage] = useState(1); + const [selected, setSelected] = useState(null); + + useEffect(() => { + setExtraWallets( + getAddressBook() + .filter((e) => STELLAR_ADDRESS_RE.test(e.address)) + .map((e) => ({ address: e.address, label: e.nickname })), + ); + }, []); + + const walletOptions = useMemo(() => { + const opts: WalletOption[] = address ? [{ address, label: "Connected wallet" }] : []; + for (const w of extraWallets) if (!opts.some((o) => o.address === w.address)) opts.push(w); + return opts; + }, [address, extraWallets]); + + // Default to the connected wallet. + useEffect(() => { + if (address && selectedWallets.length === 0) setSelectedWallets([address]); + }, [address, selectedWallets.length]); + + const load = useCallback(async (wallets: string[], more = false) => { + if (wallets.length === 0) return; + setLoading(true); + setError(null); + try { + const results = await Promise.all( + wallets + .filter((w) => !more || cursors[w]) + .map(async (w) => ({ wallet: w, ...(await fetchTransactionHistory(w, { cursor: more ? cursors[w] : null })) })), + ); + setCursors((prev) => { + const next = { ...prev }; + for (const r of results) next[r.wallet] = r.nextCursor; + return next; + }); + setRecords((prev) => mergeHistories(more ? prev : prev.filter((r) => !wallets.includes(r.wallet)), ...results.map((r) => r.records))); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load transactions."); + } finally { + setLoading(false); + } + }, [cursors]); + + // Fetch history for newly selected wallets. + useEffect(() => { + const missing = selectedWallets.filter((w) => !(w in cursors)); + if (missing.length) load(missing); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedWallets]); + + const filtered = useMemo( + () => + filterTransactions(records, { + wallets: selectedWallets, + types, + direction, + fromDate, + toDate, + asset, + search: debouncedSearch, + }), + [records, selectedWallets, types, direction, fromDate, toDate, asset, debouncedSearch], + ); + + useEffect(() => setPage(1), [selectedWallets, types, direction, fromDate, toDate, asset, debouncedSearch]); + + const pageData = paginate(filtered, page, PAGE_SIZE); + const assets = useMemo(() => Array.from(new Set(records.map((r) => r.asset))).sort(), [records]); + const canLoadMore = selectedWallets.some((w) => cursors[w]); + + const toggleWallet = (w: string) => + setSelectedWallets((prev) => (prev.includes(w) ? prev.filter((x) => x !== w) : [...prev, w])); + const toggleType = (t: TxType) => setTypes((prev) => (prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t])); + + const addWallet = () => { + const w = newWallet.trim(); + if (!STELLAR_ADDRESS_RE.test(w)) { + setError("Enter a valid Stellar public key (starts with G, 56 characters)."); + return; + } + if (!walletOptions.some((o) => o.address === w)) setExtraWallets((prev) => [...prev, { address: w, label: short(w) }]); + setSelectedWallets((prev) => (prev.includes(w) ? prev : [...prev, w])); + setNewWallet(""); + setError(null); + }; + + const resetFilters = () => { + setTypes([]); + setDirection("all"); + setFromDate(""); + setToDate(""); + setAsset(""); + setSearch(""); + }; + + const exportAs = (format: "csv" | "json") => { + const stamp = new Date().toISOString().slice(0, 10); + if (format === "csv") downloadFile(transactionsToCSV(filtered), `transactions-${stamp}.csv`, "text/csv;charset=utf-8"); + else downloadFile(transactionsToJSON(filtered), `transactions-${stamp}.json`, "application/json"); + }; + + const labelFor = (w: string) => walletOptions.find((o) => o.address === w)?.label ?? short(w); + const input = "rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm"; + + return ( +
+
+
+

Transaction history

+

On-chain payments for your wallets, straight from Horizon.

+
+
+ + +
+
+ + {/* Wallets */} +
+

Wallets

+ {walletOptions.length === 0 &&

Connect a wallet or add an address below.

} +
+ {walletOptions.map((w) => ( + + ))} +
+
+ setNewWallet(e.target.value)} placeholder="Add wallet address (G…)" aria-label="Add wallet address" className={`${input} flex-1 min-w-[16rem] font-mono`} /> + +
+
+ + {/* Filters */} +
+ setSearch(e.target.value)} placeholder="Search hash, address, memo, amount…" aria-label="Search transactions" className={`${input} sm:col-span-2`} /> + + + + +
+ {(Object.keys(TX_TYPE_LABELS) as TxType[]).map((t) => ( + + ))} + +
+
+ + {error &&

{error}

} + +

+ {loading ? "Loading…" : `${pageData.total} transaction${pageData.total === 1 ? "" : "s"} (of ${records.length} loaded)`} +

+ +
+ + + + + + + + + + + + + {pageData.items.map((r) => ( + setSelected(r)} + onKeyDown={(e) => e.key === "Enter" && setSelected(r)} + className="cursor-pointer border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/60" + > + + + + + + + + ))} + {!loading && pageData.items.length === 0 && ( + + + + )} + +
DateWalletTypeCounterpartyAmountStatus
{new Date(r.createdAt).toLocaleString()}{labelFor(r.wallet)}{TX_TYPE_LABELS[r.type]}{short(r.counterparty)} + {r.direction === "incoming" ? "+" : r.direction === "outgoing" ? "−" : ""} + {Number(r.amount).toLocaleString(undefined, { maximumFractionDigits: 7 })} {r.asset} + {r.successful ? "✓" : Failed}
No transactions match these filters.
+
+ + + + {selected && ( +
setSelected(null)}> + +
+ )} +
+ ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 387d5623..145295cd 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -14,6 +14,7 @@ import MobileSidebar from "@/components/layout/MobileSidebar"; const NAV_LINKS = [ { href: "/dashboard", label: "Dashboard" }, { href: "/dashboard/invoices", label: "Invoices" }, + { href: "/transactions", label: "Transactions" }, { href: "/subscriptions", label: "Subscriptions" }, { href: "/groups", label: "Groups" }, { href: "/address-book", label: "Contacts" }, diff --git a/src/components/layout/MobileSidebar.tsx b/src/components/layout/MobileSidebar.tsx index 66ebf889..60d0b631 100644 --- a/src/components/layout/MobileSidebar.tsx +++ b/src/components/layout/MobileSidebar.tsx @@ -8,6 +8,7 @@ import FocusTrap from "@/components/FocusTrap"; const NAV_LINKS = [ { href: "/dashboard", label: "Dashboard" }, { href: "/invoice", label: "Invoices" }, + { href: "/transactions", label: "Transactions" }, { href: "/subscriptions", label: "Subscriptions" }, { href: "/groups", label: "Groups" }, { href: "/address-book", label: "Contacts" }, diff --git a/src/lib/__tests__/transactionHistory.test.ts b/src/lib/__tests__/transactionHistory.test.ts new file mode 100644 index 00000000..208da9c0 --- /dev/null +++ b/src/lib/__tests__/transactionHistory.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from "vitest"; +import { + fetchTransactionHistory, + filterTransactions, + mergeHistories, + normalizeRecord, + paginate, + transactionsToCSV, + transactionsToJSON, + type HorizonPaymentRecord, + type TxRecord, +} from "../transactionHistory"; + +const ME = "G" + "A".repeat(55); +const BOB = "G" + "B".repeat(55); +const ALICE = "G" + "C".repeat(55); + +const raw = (over: Partial): HorizonPaymentRecord => ({ + id: "1", + paging_token: "1", + type: "payment", + created_at: "2026-10-01T12:00:00Z", + transaction_hash: "hash1", + transaction_successful: true, + from: ME, + to: BOB, + amount: "10.0000000", + asset_type: "native", + transaction: { memo: "rent", fee_charged: "100" }, + ...over, +}); + +describe("normalizeRecord", () => { + it("maps outgoing native payments", () => { + const r = normalizeRecord(raw({}), ME); + expect(r).toMatchObject({ type: "payment", direction: "outgoing", asset: "XLM", counterparty: BOB, memo: "rent" }); + }); + + it("maps incoming credit-asset path payments", () => { + const r = normalizeRecord(raw({ type: "path_payment_strict_send", from: BOB, to: ME, asset_type: "credit_alphanum4", asset_code: "USDC" }), ME); + expect(r).toMatchObject({ type: "path_payment", direction: "incoming", asset: "USDC", counterparty: BOB }); + }); + + it("maps create_account using funder/starting_balance", () => { + const r = normalizeRecord(raw({ type: "create_account", from: undefined, to: undefined, funder: BOB, account: ME, starting_balance: "5" }), ME); + expect(r).toMatchObject({ type: "create_account", direction: "incoming", amount: "5", from: BOB }); + }); +}); + +const rec = (over: Partial): TxRecord => ({ + ...normalizeRecord(raw({}), ME), + ...over, +}); + +const sample: TxRecord[] = [ + rec({ id: "1", createdAt: "2026-10-01T10:00:00Z", hash: "aaa", type: "payment", direction: "outgoing", asset: "XLM" }), + rec({ id: "2", createdAt: "2026-10-05T10:00:00Z", hash: "bbb", type: "path_payment", direction: "incoming", asset: "USDC", from: ALICE, memo: "invoice 42" }), + rec({ id: "3", createdAt: "2026-10-10T10:00:00Z", hash: "ccc", type: "create_account", direction: "incoming", wallet: BOB }), +]; + +describe("filterTransactions", () => { + it("filters by each criterion independently", () => { + expect(filterTransactions(sample, { wallets: [ME] })).toHaveLength(2); + expect(filterTransactions(sample, { types: ["path_payment"] }).map((r) => r.id)).toEqual(["2"]); + expect(filterTransactions(sample, { direction: "incoming" })).toHaveLength(2); + expect(filterTransactions(sample, { asset: "usdc" }).map((r) => r.id)).toEqual(["2"]); + expect(filterTransactions(sample, { fromDate: "2026-10-05", toDate: "2026-10-05" }).map((r) => r.id)).toEqual(["2"]); + }); + + it("combines filters", () => { + expect(filterTransactions(sample, { wallets: [ME], direction: "incoming", fromDate: "2026-10-02" }).map((r) => r.id)).toEqual(["2"]); + expect(filterTransactions(sample, { wallets: [BOB], types: ["payment"] })).toHaveLength(0); + }); + + it("searches hash, addresses and memo case-insensitively", () => { + expect(filterTransactions(sample, { search: "BBB" }).map((r) => r.id)).toEqual(["2"]); + expect(filterTransactions(sample, { search: "invoice 42" }).map((r) => r.id)).toEqual(["2"]); + expect(filterTransactions(sample, { search: ALICE.slice(0, 10) }).map((r) => r.id)).toEqual(["2"]); + }); +}); + +describe("mergeHistories & paginate", () => { + it("dedupes and sorts newest first", () => { + const merged = mergeHistories(sample, [sample[0]]); + expect(merged.map((r) => r.id)).toEqual(["3", "2", "1"]); + }); + + it("clamps pages", () => { + const items = Array.from({ length: 45 }, (_, i) => i); + expect(paginate(items, 1, 20).items).toHaveLength(20); + expect(paginate(items, 3, 20)).toMatchObject({ page: 3, totalPages: 3, total: 45 }); + expect(paginate(items, 3, 20).items).toHaveLength(5); + expect(paginate(items, 99, 20).page).toBe(3); + expect(paginate([], 1, 20)).toMatchObject({ page: 1, totalPages: 1, total: 0 }); + }); +}); + +describe("export", () => { + it("produces CSV with header, escaping and formula protection", () => { + const csv = transactionsToCSV([rec({ memo: 'say "hi", ok' }), rec({ memo: "=HYPERLINK()" })]); + const lines = csv.split("\n"); + expect(lines[0]).toBe("createdAt,wallet,type,direction,amount,asset,from,to,memo,feeCharged,successful,hash"); + expect(lines[1]).toContain('"say ""hi"", ok"'); + expect(lines[2]).toContain("'=HYPERLINK()"); + expect(lines).toHaveLength(3); + }); + + it("produces valid JSON", () => { + expect(JSON.parse(transactionsToJSON(sample))).toHaveLength(3); + }); +}); + +describe("fetchTransactionHistory", () => { + it("requests Horizon payments and returns a cursor when the page is full", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ _embedded: { records: [raw({ id: "9", paging_token: "pt9" })] } }), + }); + const res = await fetchTransactionHistory(ME, { limit: 1, horizonUrl: "https://h.example/", fetchImpl: fetchImpl as never }); + expect(fetchImpl.mock.calls[0][0]).toBe(`https://h.example/accounts/${ME}/payments?order=desc&limit=1&join=transactions`); + expect(res.records[0].id).toBe("9"); + expect(res.nextCursor).toBe("pt9"); + }); + + it("treats 404 (unfunded account) as empty and throws on other errors", async () => { + const notFound = vi.fn().mockResolvedValue({ ok: false, status: 404 }); + expect(await fetchTransactionHistory(ME, { fetchImpl: notFound as never })).toEqual({ records: [], nextCursor: null }); + const broken = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + await expect(fetchTransactionHistory(ME, { fetchImpl: broken as never })).rejects.toThrow("500"); + }); +}); diff --git a/src/lib/transactionHistory.ts b/src/lib/transactionHistory.ts new file mode 100644 index 00000000..5943764e --- /dev/null +++ b/src/lib/transactionHistory.ts @@ -0,0 +1,248 @@ +/** + * On-chain transaction history for one or more wallets, fetched from Horizon's + * `/accounts/{id}/payments` endpoint and normalised for filtering, searching, + * pagination and export. + */ + +// Same default as `src/lib/stellar.ts`, duplicated to avoid pulling the SDK into this module. +const HORIZON_URL = + process.env.NEXT_PUBLIC_HORIZON_URL ?? + (process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" + ? "https://horizon.stellar.org" + : "https://horizon-testnet.stellar.org"); +const EXPLORER_BASE = + process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" + ? "https://stellar.expert/explorer/public" + : "https://stellar.expert/explorer/testnet"; + +export function explorerTxLink(hash: string): string { + return `${EXPLORER_BASE}/tx/${hash}`; +} + +export type TxType = "payment" | "path_payment" | "create_account" | "account_merge" | "contract"; +export type TxDirection = "incoming" | "outgoing" | "self"; + +export const TX_TYPE_LABELS: Record = { + payment: "Payment", + path_payment: "Path payment", + create_account: "Account created", + account_merge: "Account merge", + contract: "Contract call", +}; + +export interface TxRecord { + id: string; + pagingToken: string; + wallet: string; + hash: string; + type: TxType; + direction: TxDirection; + /** Decimal string as returned by Horizon, e.g. "12.5000000". */ + amount: string; + asset: string; + from: string; + to: string; + counterparty: string; + createdAt: string; + memo: string; + feeCharged: string; + successful: boolean; +} + +/** Subset of a Horizon payment-operation record that we read. */ +export interface HorizonPaymentRecord { + id: string; + paging_token: string; + type: string; + created_at: string; + transaction_hash: string; + transaction_successful?: boolean; + from?: string; + to?: string; + amount?: string; + asset_type?: string; + asset_code?: string; + funder?: string; + account?: string; + starting_balance?: string; + into?: string; + source_account?: string; + transaction?: { memo?: string; fee_charged?: string | number }; +} + +function mapType(type: string): TxType { + if (type.startsWith("path_payment")) return "path_payment"; + if (type === "create_account") return "create_account"; + if (type === "account_merge") return "account_merge"; + if (type === "invoke_host_function") return "contract"; + return "payment"; +} + +export function normalizeRecord(rec: HorizonPaymentRecord, wallet: string): TxRecord { + const type = mapType(rec.type); + let from = rec.from ?? rec.source_account ?? ""; + let to = rec.to ?? ""; + let amount = rec.amount ?? "0"; + let asset = rec.asset_type === "native" || !rec.asset_type ? "XLM" : rec.asset_code ?? "UNKNOWN"; + + if (type === "create_account") { + from = rec.funder ?? from; + to = rec.account ?? ""; + amount = rec.starting_balance ?? "0"; + asset = "XLM"; + } else if (type === "account_merge") { + from = rec.account ?? from; + to = rec.into ?? ""; + asset = "XLM"; + } + + const direction: TxDirection = from === wallet && to === wallet ? "self" : to === wallet ? "incoming" : "outgoing"; + + return { + id: rec.id, + pagingToken: rec.paging_token, + wallet, + hash: rec.transaction_hash, + type, + direction, + amount, + asset, + from, + to, + counterparty: direction === "incoming" ? from : to, + createdAt: rec.created_at, + memo: rec.transaction?.memo ?? "", + feeCharged: rec.transaction?.fee_charged != null ? String(rec.transaction.fee_charged) : "", + successful: rec.transaction_successful !== false, + }; +} + +export interface FetchHistoryResult { + records: TxRecord[]; + /** Cursor for the next (older) page, or null when exhausted. */ + nextCursor: string | null; +} + +/** Fetches one page (newest first) of a wallet's payment operations. */ +export async function fetchTransactionHistory( + wallet: string, + { + cursor, + limit = 100, + horizonUrl = HORIZON_URL, + fetchImpl = fetch, + }: { cursor?: string | null; limit?: number; horizonUrl?: string; fetchImpl?: typeof fetch } = {}, +): Promise { + const params = new URLSearchParams({ order: "desc", limit: String(limit), join: "transactions" }); + if (cursor) params.set("cursor", cursor); + const url = `${horizonUrl.replace(/\/+$/, "")}/accounts/${encodeURIComponent(wallet)}/payments?${params}`; + const res = await fetchImpl(url, { headers: { Accept: "application/json" } }); + if (res.status === 404) return { records: [], nextCursor: null }; // unfunded account + if (!res.ok) throw new Error(`Horizon request failed (${res.status})`); + const body = (await res.json()) as { _embedded?: { records?: HorizonPaymentRecord[] } }; + const raw = body._embedded?.records ?? []; + const records = raw.map((r) => normalizeRecord(r, wallet)); + return { records, nextCursor: raw.length === limit ? raw[raw.length - 1].paging_token : null }; +} + +// ── Filtering & search ────────────────────────────────────────────────────── + +export interface TxFilters { + wallets?: string[]; + types?: TxType[]; + direction?: TxDirection | "all"; + /** Inclusive, YYYY-MM-DD. */ + fromDate?: string; + /** Inclusive, YYYY-MM-DD. */ + toDate?: string; + asset?: string; + search?: string; +} + +export function filterTransactions(records: TxRecord[], filters: TxFilters): TxRecord[] { + const from = filters.fromDate ? new Date(`${filters.fromDate}T00:00:00Z`).getTime() : null; + const to = filters.toDate ? new Date(`${filters.toDate}T23:59:59.999Z`).getTime() : null; + const q = filters.search?.trim().toLowerCase() ?? ""; + const asset = filters.asset?.trim().toUpperCase() ?? ""; + + return records.filter((r) => { + if (filters.wallets?.length && !filters.wallets.includes(r.wallet)) return false; + if (filters.types?.length && !filters.types.includes(r.type)) return false; + if (filters.direction && filters.direction !== "all" && r.direction !== filters.direction) return false; + if (asset && r.asset.toUpperCase() !== asset) return false; + const t = new Date(r.createdAt).getTime(); + if (from !== null && t < from) return false; + if (to !== null && t > to) return false; + if (q) { + const haystack = [r.hash, r.from, r.to, r.memo, r.amount, r.asset, TX_TYPE_LABELS[r.type]] + .join(" ") + .toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); +} + +/** Merge records from several wallets, de-duplicated and newest first. */ +export function mergeHistories(...lists: TxRecord[][]): TxRecord[] { + const seen = new Map(); + for (const list of lists) for (const r of list) seen.set(`${r.wallet}:${r.id}`, r); + return Array.from(seen.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +export function paginate(items: T[], page: number, pageSize: number) { + const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); + const current = Math.min(Math.max(1, page), totalPages); + return { + items: items.slice((current - 1) * pageSize, current * pageSize), + page: current, + totalPages, + total: items.length, + }; +} + +// ── Export ────────────────────────────────────────────────────────────────── + +const CSV_COLUMNS: (keyof TxRecord)[] = [ + "createdAt", + "wallet", + "type", + "direction", + "amount", + "asset", + "from", + "to", + "memo", + "feeCharged", + "successful", + "hash", +]; + +function csvCell(value: unknown): string { + let s = String(value ?? ""); + // Neutralise spreadsheet formula injection. + if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`; + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function transactionsToCSV(records: TxRecord[]): string { + const header = CSV_COLUMNS.join(","); + const rows = records.map((r) => CSV_COLUMNS.map((c) => csvCell(r[c])).join(",")); + return [header, ...rows].join("\n"); +} + +export function transactionsToJSON(records: TxRecord[]): string { + return JSON.stringify(records, null, 2); +} + +export function downloadFile(content: string, filename: string, mime: string): void { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} From 16003c73b3fd15d8cc3b9a6ddd6d3b6e54b9ea96 Mon Sep 17 00:00:00 2001 From: adenueltech Date: Sat, 17 Oct 2026 21:19:11 +0100 Subject: [PATCH 2/2] test: use unambiguous search term in transaction history test Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/__tests__/transactionHistory.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/transactionHistory.test.ts b/src/lib/__tests__/transactionHistory.test.ts index 208da9c0..6eceb7b8 100644 --- a/src/lib/__tests__/transactionHistory.test.ts +++ b/src/lib/__tests__/transactionHistory.test.ts @@ -54,7 +54,7 @@ const rec = (over: Partial): TxRecord => ({ const sample: TxRecord[] = [ rec({ id: "1", createdAt: "2026-10-01T10:00:00Z", hash: "aaa", type: "payment", direction: "outgoing", asset: "XLM" }), - rec({ id: "2", createdAt: "2026-10-05T10:00:00Z", hash: "bbb", type: "path_payment", direction: "incoming", asset: "USDC", from: ALICE, memo: "invoice 42" }), + rec({ id: "2", createdAt: "2026-10-05T10:00:00Z", hash: "hash-two-xyz", type: "path_payment", direction: "incoming", asset: "USDC", from: ALICE, memo: "invoice 42" }), rec({ id: "3", createdAt: "2026-10-10T10:00:00Z", hash: "ccc", type: "create_account", direction: "incoming", wallet: BOB }), ]; @@ -73,7 +73,7 @@ describe("filterTransactions", () => { }); it("searches hash, addresses and memo case-insensitively", () => { - expect(filterTransactions(sample, { search: "BBB" }).map((r) => r.id)).toEqual(["2"]); + expect(filterTransactions(sample, { search: "XYZ" }).map((r) => r.id)).toEqual(["2"]); expect(filterTransactions(sample, { search: "invoice 42" }).map((r) => r.id)).toEqual(["2"]); expect(filterTransactions(sample, { search: ALICE.slice(0, 10) }).map((r) => r.id)).toEqual(["2"]); });