From e319131ba2e8085e92a6944a26f9730f0d87c1bc Mon Sep 17 00:00:00 2001 From: legend-esc Date: Sat, 17 Oct 2026 19:41:32 +0100 Subject: [PATCH 1/4] feat: replace history placeholder with shared activity store --- src/App.tsx | 3 +- src/pages/History.tsx | 220 ++++++++++++++++++++++++++++++------ src/stores/activityStore.ts | 2 + 3 files changed, 190 insertions(+), 35 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 20fc307..f1f60d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import Names from '@/pages/Names'; import NameProfile from '@/pages/NameProfile'; import NamesAuctions from '@/pages/NamesAuctions'; import Activity from '@/pages/Activity'; +import History from '@/pages/History'; import Portfolio from '@/pages/Portfolio'; import Debug from '@/pages/Debug'; import { useChain } from '@/context/ChainContext'; @@ -223,7 +224,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/History.tsx b/src/pages/History.tsx index a68bf55..729f23a 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -1,45 +1,197 @@ +import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useChain } from '@/context/ChainContext'; -import { StellarHistory } from '@/components/StellarHistory'; +import { ActivityRow } from '@/components/ActivityRow'; import { EmptyState } from '@/components/EmptyState'; +import { + useActivityStore, + type ActivityEntry, + type ActivityKind, + type ActivityStatus, +} from '@/stores/activityStore'; +import { downloadActivityCsv, downloadActivityJson } from '@/utils/activityExport'; + +type HistoryChain = 'all' | 'horizen' | 'stellar' | 'solana' | 'ckb'; + +const CHAINS: HistoryChain[] = ['all', 'horizen', 'stellar', 'solana', 'ckb']; export default function History() { const navigate = useNavigate(); - const { chain } = useChain(); + const { entries, clearAll } = useActivityStore(); + const [filterKind, setFilterKind] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + const [filterChain, setFilterChain] = useState('all'); + + const filteredEntries = useMemo(() => { + return entries + .filter((entry: ActivityEntry) => { + if (filterChain !== 'all' && entry.chain !== filterChain) return false; + if (filterKind !== 'all' && entry.kind !== filterKind) return false; + if (filterStatus !== 'all' && entry.status !== filterStatus) return false; + return true; + }) + .sort((a: ActivityEntry, b: ActivityEntry) => b.timestamp - a.timestamp); + }, [entries, filterChain, filterKind, filterStatus]); - if (chain === 'stellar') return ; + // Exports deliberately omit `metadata` so private key and memo material + // is never leaked into CSV or JSON downloads. + const exportEntries = useMemo(() => entries.map(({ metadata, ...rest }) => rest), [entries]); return ( -
-
-

- History -

-
- +
+
+ + Multi-Chain Activity + +

+ Activity History +

+
+ +
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + {filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'} + +
+ +
+ +
+ Export JSON + +

+ Exports include chain, status, amount, timestamp, and explorer links only. Private key + and memo material is never included. +

+
+
+ + {entries.length === 0 && ( + + + + + + + + + } + title="No activity recorded yet." + description="Send or receive a stealth payment to see your history here." + primaryCTA={{ label: 'Send your first payment', onClick: () => navigate('/send') }} + /> + )} + + {entries.length > 0 && filteredEntries.length === 0 && ( + + )} + +
+ {filteredEntries.map((entry: ActivityEntry) => ( + + ))} +
+ ); } diff --git a/src/stores/activityStore.ts b/src/stores/activityStore.ts index 5d972dc..936745c 100644 --- a/src/stores/activityStore.ts +++ b/src/stores/activityStore.ts @@ -30,6 +30,7 @@ interface ActivityState { addEntry: (entry: ActivityEntry) => void; updateStatus: (id: string, status: ActivityStatus) => void; clearHistory: (chain: string, wallet: string) => void; + clearAll: () => void; pollPending: () => Promise; } @@ -52,6 +53,7 @@ export const useActivityStore = create()( set((state) => ({ entries: state.entries.filter((e) => !(e.chain === chain && e.wallet === wallet)), })), + clearAll: () => set({ entries: [] }), pollPending: async () => { const { entries, updateStatus } = get(); const pendingTxs = entries.filter((e) => e.status === 'pending' && e.chain === 'stellar'); From 569c5596be045451caad7cb7d4f555591304518d Mon Sep 17 00:00:00 2001 From: legend-esc Date: Thu, 22 Oct 2026 11:36:15 +0100 Subject: [PATCH 2/4] fix(history): restore connected-wallet privacy scope - Require a connected wallet address before rendering any entries; disconnected state shows a prompt matching Activity.tsx - Introduce walletEntries filtered by entry.wallet === address so chain/kind/status filters only operate on the current wallet's data - Derive exportEntries from filteredEntries (not raw entries) and strip metadata, preventing cross-wallet leakage in CSV/JSON downloads - Replace clearAll() with clearHistory(chain, address) so clearing is scoped to the connected wallet - Add History.test.ts with 7 cross-wallet isolation tests covering view scoping, wallet-switch isolation, null-address guard, export scoping, metadata stripping, and scoped clearHistory --- src/pages/History.test.ts | 178 ++++++++++++++++++++++++++++++++++++++ src/pages/History.tsx | 50 ++++++++--- 2 files changed, 216 insertions(+), 12 deletions(-) create mode 100644 src/pages/History.test.ts diff --git a/src/pages/History.test.ts b/src/pages/History.test.ts new file mode 100644 index 0000000..e32da45 --- /dev/null +++ b/src/pages/History.test.ts @@ -0,0 +1,178 @@ +/** + * Cross-wallet isolation tests for the History page. + * + * These tests verify that the scoping logic used in History.tsx (and mirrored + * in Activity.tsx) prevents entries belonging to one wallet from leaking into + * another wallet's view or export. They work directly against the zustand + * store so they run in the default Node environment without a DOM renderer. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useActivityStore, type ActivityEntry } from '@/stores/activityStore'; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function makeEntry( + overrides: Partial & { id: string; wallet: string }, +): ActivityEntry { + return { + chain: 'stellar', + kind: 'stealth-send', + direction: 'out', + status: 'confirmed', + amount: '10', + timestamp: Date.now(), + metadata: { memo: 'secret', privateKey: 'sk_very_private' }, + ...overrides, + }; +} + +/** + * Simulate the wallet-scoped filtering that History.tsx performs. + * Returns entries whose `wallet` field matches the supplied address. + */ +function walletEntries(entries: ActivityEntry[], address: string): ActivityEntry[] { + return entries.filter((e) => e.wallet === address); +} + +/** + * Simulate the export projection that History.tsx performs: + * scope to filteredEntries (already wallet-scoped) then strip metadata. + */ +function exportEntries( + entries: ActivityEntry[], + address: string, +): Omit[] { + return walletEntries(entries, address).map(({ metadata: _m, ...rest }) => rest); +} + +// ─── tests ────────────────────────────────────────────────────────────────── + +describe('History page — cross-wallet isolation', () => { + beforeEach(() => { + // Reset store to a clean slate before every test. + useActivityStore.setState({ entries: [] }); + }); + + it('shows only entries that belong to the connected wallet', () => { + const { addEntry, entries: _ } = useActivityStore.getState(); + + addEntry(makeEntry({ id: 'tx-alice-1', wallet: 'GALICE' })); + addEntry(makeEntry({ id: 'tx-alice-2', wallet: 'GALICE' })); + addEntry(makeEntry({ id: 'tx-bob-1', wallet: 'GBOB' })); + addEntry(makeEntry({ id: 'tx-bob-2', wallet: 'GBOB' })); + + const { entries } = useActivityStore.getState(); + + const aliceView = walletEntries(entries, 'GALICE'); + const bobView = walletEntries(entries, 'GBOB'); + + expect(aliceView).toHaveLength(2); + expect(aliceView.every((e) => e.wallet === 'GALICE')).toBe(true); + + expect(bobView).toHaveLength(2); + expect(bobView.every((e) => e.wallet === 'GBOB')).toBe(true); + }); + + it("does not expose another wallet's entries when the wallet switches", () => { + const { addEntry } = useActivityStore.getState(); + + addEntry(makeEntry({ id: 'tx-alice-1', wallet: 'GALICE' })); + addEntry(makeEntry({ id: 'tx-bob-1', wallet: 'GBOB' })); + + const { entries } = useActivityStore.getState(); + + // Simulate Freighter returning a different address after wallet switch. + const aliceView = walletEntries(entries, 'GALICE'); + const bobView = walletEntries(entries, 'GBOB'); + + // Alice's view must not contain Bob's transaction. + expect(aliceView.some((e) => e.wallet === 'GBOB')).toBe(false); + + // Bob's view must not contain Alice's transaction. + expect(bobView.some((e) => e.wallet === 'GALICE')).toBe(false); + }); + + it('returns an empty list when no wallet is connected (address is null)', () => { + const { addEntry } = useActivityStore.getState(); + addEntry(makeEntry({ id: 'tx-alice-1', wallet: 'GALICE' })); + + const { entries } = useActivityStore.getState(); + + // History.tsx returns [] when address is null — replicate that guard. + const connectedAddress: string | null = null; + const view = connectedAddress ? walletEntries(entries, connectedAddress) : []; + + expect(view).toHaveLength(0); + }); + + it("exports only the connected wallet's filtered entries, with metadata stripped", () => { + const { addEntry } = useActivityStore.getState(); + + addEntry(makeEntry({ id: 'tx-alice-1', wallet: 'GALICE', metadata: { memo: 'alice-secret' } })); + addEntry(makeEntry({ id: 'tx-bob-1', wallet: 'GBOB', metadata: { memo: 'bob-secret' } })); + + const { entries } = useActivityStore.getState(); + + const aliceExport = exportEntries(entries, 'GALICE'); + + // Only Alice's entry is exported. + expect(aliceExport).toHaveLength(1); + expect(aliceExport[0].wallet).toBe('GALICE'); + + // metadata must be stripped from every exported entry. + expect('metadata' in aliceExport[0]).toBe(false); + }); + + it('export does not contain entries from other wallets even when filters are wide-open', () => { + const { addEntry } = useActivityStore.getState(); + + addEntry(makeEntry({ id: 'tx-alice-1', wallet: 'GALICE', chain: 'stellar' })); + addEntry(makeEntry({ id: 'tx-alice-2', wallet: 'GALICE', chain: 'horizen' })); + addEntry(makeEntry({ id: 'tx-bob-1', wallet: 'GBOB', chain: 'stellar' })); + + const { entries } = useActivityStore.getState(); + + const aliceExport = exportEntries(entries, 'GALICE'); + + expect(aliceExport.every((e) => e.wallet === 'GALICE')).toBe(true); + expect(aliceExport.some((e) => e.wallet === 'GBOB')).toBe(false); + }); + + it('exported entries never contain the metadata field regardless of its value', () => { + const { addEntry } = useActivityStore.getState(); + + // Entry without metadata. + addEntry(makeEntry({ id: 'tx-1', wallet: 'GALICE', metadata: undefined })); + // Entry with rich metadata. + addEntry( + makeEntry({ id: 'tx-2', wallet: 'GALICE', metadata: { key: 'sk_private', memo: 'hello' } }), + ); + // Entry with null metadata. + addEntry(makeEntry({ id: 'tx-3', wallet: 'GALICE', metadata: null })); + + const { entries } = useActivityStore.getState(); + const exported = exportEntries(entries, 'GALICE'); + + expect(exported).toHaveLength(3); + for (const entry of exported) { + expect('metadata' in entry).toBe(false); + } + }); + + it('clearHistory scoped to the connected wallet leaves other wallets untouched', () => { + const { addEntry, clearHistory } = useActivityStore.getState(); + + addEntry(makeEntry({ id: 'tx-alice-1', wallet: 'GALICE', chain: 'stellar' })); + addEntry(makeEntry({ id: 'tx-bob-1', wallet: 'GBOB', chain: 'stellar' })); + + clearHistory('stellar', 'GALICE'); + + const { entries } = useActivityStore.getState(); + + // Alice's entry is gone. + expect(entries.some((e) => e.wallet === 'GALICE')).toBe(false); + + // Bob's entry survives. + expect(entries.some((e) => e.wallet === 'GBOB')).toBe(true); + }); +}); diff --git a/src/pages/History.tsx b/src/pages/History.tsx index 729f23a..c678883 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ActivityRow } from '@/components/ActivityRow'; import { EmptyState } from '@/components/EmptyState'; +import { useStellarWallet } from '@/context/StellarWalletContext'; import { useActivityStore, type ActivityEntry, @@ -16,13 +17,22 @@ const CHAINS: HistoryChain[] = ['all', 'horizen', 'stellar', 'solana', 'ckb']; export default function History() { const navigate = useNavigate(); - const { entries, clearAll } = useActivityStore(); + // Scope all reads to the connected wallet address so entries from other + // wallets/profiles are never visible or exportable from this session. + const { address } = useStellarWallet(); + const { entries, clearHistory } = useActivityStore(); const [filterKind, setFilterKind] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const [filterChain, setFilterChain] = useState('all'); + // Only the entries that belong to the currently connected wallet. + const walletEntries = useMemo(() => { + if (!address) return []; + return entries.filter((entry: ActivityEntry) => entry.wallet === address); + }, [entries, address]); + const filteredEntries = useMemo(() => { - return entries + return walletEntries .filter((entry: ActivityEntry) => { if (filterChain !== 'all' && entry.chain !== filterChain) return false; if (filterKind !== 'all' && entry.kind !== filterKind) return false; @@ -30,11 +40,27 @@ export default function History() { return true; }) .sort((a: ActivityEntry, b: ActivityEntry) => b.timestamp - a.timestamp); - }, [entries, filterChain, filterKind, filterStatus]); + }, [walletEntries, filterChain, filterKind, filterStatus]); + + // Exports are scoped to the current filter view and deliberately omit + // `metadata` so private key and memo material is never leaked into downloads. + const exportEntries = useMemo( + () => filteredEntries.map(({ metadata: _metadata, ...rest }) => rest), + [filteredEntries], + ); - // Exports deliberately omit `metadata` so private key and memo material - // is never leaked into CSV or JSON downloads. - const exportEntries = useMemo(() => entries.map(({ metadata, ...rest }) => rest), [entries]); + if (!address) { + return ( +
+

+ Activity History +

+

+ Connect your wallet to view and export your transaction history. +

+
+ ); + } return (
@@ -49,8 +75,8 @@ export default function History() {

- Exports include chain, status, amount, timestamp, and explorer links only. Private key - and memo material is never included. + Exports include only the entries matching the current filters. Private key and memo + material is never included.

- {entries.length === 0 && ( + {walletEntries.length === 0 && ( )} - {entries.length > 0 && filteredEntries.length === 0 && ( + {walletEntries.length > 0 && filteredEntries.length === 0 && ( Date: Fri, 23 Oct 2026 11:58:47 +0100 Subject: [PATCH 3/4] fix(history): clear connected wallet across all chains in all-chains view - Update clearHistory to support chain === 'all', clearing all entries for the wallet across all chains while preserving other wallets - Pass filterChain directly in History.tsx and Activity.tsx so All Chains clears multi-chain activity for the connected wallet - Add tests in History.test.tsx covering all-chain and single-chain clearing helper behavior and component DOM interactions --- src/pages/Activity.tsx | 2 +- src/pages/History.test.ts | 178 ---------------- src/pages/History.test.tsx | 400 ++++++++++++++++++++++++++++++++++++ src/pages/History.tsx | 2 +- src/stores/activityStore.ts | 11 +- 5 files changed, 410 insertions(+), 183 deletions(-) delete mode 100644 src/pages/History.test.ts create mode 100644 src/pages/History.test.tsx diff --git a/src/pages/Activity.tsx b/src/pages/Activity.tsx index f0fd7b8..362ed1e 100644 --- a/src/pages/Activity.tsx +++ b/src/pages/Activity.tsx @@ -62,7 +62,7 @@ export default function Activity() {