diff --git a/e2e/history.spec.ts b/e2e/history.spec.ts new file mode 100644 index 0000000..c37294d --- /dev/null +++ b/e2e/history.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Activity History', () => { + test.beforeEach(async ({ page }) => { + // Navigate to history page + await page.goto('/history'); + + // Simulate connecting a wallet and populating localStorage with mock data + await page.evaluate(() => { + // Mock wallet connection state if necessary + window.localStorage.setItem('wraith-wallet', JSON.stringify({ address: 'GDTESTWALLET123' })); + + // Mock history store + window.localStorage.setItem('wraith-activity-storage', JSON.stringify({ + state: { + entries: [ + { + id: 'tx1', + chain: 'stellar', + wallet: 'GDTESTWALLET123', + kind: 'stealth-send', + direction: 'out', + status: 'confirmed', + amount: '10', + timestamp: Date.now() - 1000, + }, + { + id: 'tx2', + chain: 'stellar', + wallet: 'GDTESTWALLET123', + kind: 'withdrawal', + direction: 'out', + status: 'pending', + amount: '5', + timestamp: Date.now() - 2000, + }, + { + id: 'tx3', + chain: 'stellar', + wallet: 'GDTESTWALLET123', + kind: 'stealth-receive', + direction: 'in', + status: 'confirmed', + timestamp: Date.now() - 3000, + } + ] + }, + version: 0 + })); + }); + + // Reload to apply localStorage + await page.reload(); + }); + + test('displays history entries and filters correctly', async ({ page }) => { + // Wait for the history page to load + await expect(page.getByText('Activity History')).toBeVisible(); + + // Check if all 3 items are shown initially + await expect(page.getByText('stealth send')).toBeVisible(); + await expect(page.getByText('withdrawal')).toBeVisible(); + await expect(page.getByText('stealth receive')).toBeVisible(); + + // Filter by type: withdrawal + await page.locator('select').first().selectOption('withdrawal'); + await expect(page.getByText('withdrawal')).toBeVisible(); + await expect(page.getByText('stealth send')).not.toBeVisible(); + + // Filter by status: pending + await page.locator('select').nth(1).selectOption('pending'); + await expect(page.getByText('withdrawal')).toBeVisible(); + + // Clear history + await page.getByRole('button', { name: 'Clear History' }).click(); + await expect(page.getByText('No activity recorded yet.')).toBeVisible(); + }); +}); diff --git a/src/components/StellarHistory.tsx b/src/components/StellarHistory.tsx new file mode 100644 index 0000000..abfe61c --- /dev/null +++ b/src/components/StellarHistory.tsx @@ -0,0 +1,183 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useStellarWallet } from '@/context/StellarWalletContext'; +import { stellarTxUrl } from '@/lib/explorer'; +import { useActivityStore, ActivityKind, ActivityStatus } from '@/stores/activityStore'; + +export function StellarHistory() { + const { address, isConnected } = useStellarWallet(); + const { entries, clearHistory, pollPending } = useActivityStore(); + + const [filterKind, setFilterKind] = useState('all'); + const [filterStatus, setFilterStatus] = useState('all'); + + // Poll for pending transactions on mount and every 10 seconds + useEffect(() => { + if (!address) return; + pollPending(); + const interval = setInterval(pollPending, 10000); + return () => clearInterval(interval); + }, [address, pollPending]); + + const walletEntries = useMemo(() => { + if (!address) return []; + return entries.filter((e) => e.wallet === address && e.chain === 'stellar'); + }, [entries, address]); + + const filteredEntries = useMemo(() => { + return walletEntries.filter((e) => { + if (filterKind !== 'all' && e.kind !== filterKind) return false; + if (filterStatus !== 'all' && e.status !== filterStatus) return false; + return true; + }).sort((a, b) => b.timestamp - a.timestamp); + }, [walletEntries, filterKind, filterStatus]); + + if (!isConnected) { + return ( +
+

+ History +

+

+ Connect your Stellar wallet to view your transaction history. +

+
+ ); + } + + return ( +
+
+
+ + Stellar Testnet / XLM + +

+ Activity History +

+
+ +
+ +
+
+ + +
+
+ + +
+
+ + {walletEntries.length === 0 && ( +

No activity recorded yet.

+ )} + + {walletEntries.length > 0 && filteredEntries.length === 0 && ( +

No activity matches the filters.

+ )} + +
+ {filteredEntries.map((tx) => ( +
+
+
+ + + {tx.status} • {tx.kind.replace('-', ' ')} + +
+ + {new Date(tx.timestamp).toLocaleString()} + +
+ +
+
+ + Direction + + + {tx.direction === 'in' ? 'Incoming (Received)' : 'Outgoing (Sent)'} + +
+ + {tx.amount && ( +
+ + Amount + + + {tx.amount} XLM + +
+ )} + + {tx.recipient && ( +
+ + Recipient / Address + + + {tx.recipient.length > 30 ? `${tx.recipient.slice(0, 12)}...${tx.recipient.slice(-12)}` : tx.recipient} + +
+ )} + + {tx.kind !== 'stealth-receive' && ( + + )} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index 28b0b1b..ea4ace1 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -22,6 +22,7 @@ import { useStellarWallet } from '@/context/StellarWalletContext'; import { StellarMatchCard } from '@/components/StellarMatchCard'; import { StellarReceiveView } from '@/components/StellarReceiveView'; import { STELLAR_NETWORK } from '@/config'; +import { useActivityStore } from '@/stores/activityStore'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5'; @@ -37,6 +38,8 @@ function StellarMatchCardContainer({ const [balance, setBalance] = useState(null); const [balanceState, setBalanceState] = useState<'loading' | 'loaded' | 'error'>('loading'); const [dest, setDest] = useState(''); + const addActivity = useActivityStore((state) => state.addEntry); + const updateActivity = useActivityStore((state) => state.updateStatus); const [withdrawing, setWithdrawing] = useState(false); const [withdrawHash, setWithdrawHash] = useState(null); const [feeBumpHash, setFeeBumpHash] = useState(null); @@ -117,19 +120,32 @@ function StellarMatchCardContainer({ .setTimeout(30) .build(); - const txHash = tx.hash(); + const txHashHex = tx.hash().toString('hex'); const signature = signStellarTransaction( - txHash, + txHashHex, match.stealthPrivateScalar, match.stealthPubKeyBytes, ); const signatureBase64 = Buffer.from(signature).toString('base64'); tx.addSignature(match.stealthAddress, signatureBase64); + const signedXdrStr = encodeURIComponent(tx.toXDR()); + addActivity({ + id: txHashHex, + chain: 'stellar', + wallet: address || '', + kind: 'withdrawal', + direction: 'out', + status: 'pending', + amount: sendableAmount, + recipient: dest, + timestamp: Date.now(), + }); + const submitRes = await fetch(`${horizonUrl}/transactions`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `tx=${encodeURIComponent(tx.toXDR())}`, + body: `tx=${signedXdrStr}`, }); const submitData = await submitRes.json(); @@ -140,9 +156,11 @@ function StellarMatchCardContainer({ } setWithdrawHash(submitData.hash); + updateActivity(txHashHex, 'confirmed'); onWithdrawn(); } catch (err) { setError(err instanceof Error ? err.message : 'Withdraw failed'); + // In a real robust implementation we'd check if we submitted and mark failed } finally { setWithdrawing(false); } @@ -210,6 +228,18 @@ function StellarMatchCardContainer({ const feeBumpXdr = feeBumpTx.toXDR(); const signedFeeBumpXdr = await signTransaction(feeBumpXdr); + const txHashHex = feeBumpTx.hash().toString('hex'); + addActivity({ + id: txHashHex, + chain: 'stellar', + wallet: address || '', + kind: 'withdrawal', + direction: 'out', + status: 'pending', + recipient: dest, + timestamp: Date.now(), + }); + // Submit fee-bump transaction const submitRes = await fetch(`${horizonUrl}/transactions`, { method: 'POST', @@ -227,6 +257,7 @@ function StellarMatchCardContainer({ // Fee-bump transactions return the outer hash setFeeBumpHash(submitData.hash); setWithdrawHash(submitData.hash); // For UI consistency + updateActivity(txHashHex, 'confirmed'); onWithdrawn(); } catch (err) { setError(err instanceof Error ? err.message : 'Sponsored withdraw failed'); @@ -261,6 +292,8 @@ export function StellarReceive() { const { address, isConnected, signMessage, signTransaction } = useStellarWallet(); const { stellarKeys, stellarMetaAddress, setStellarKeys, setStellarMetaAddress } = useStealthKeys(); + const addActivity = useActivityStore((state) => state.addEntry); + const updateActivity = useActivityStore((state) => state.updateStatus); const [isDerivingKeys, setIsDerivingKeys] = useState(false); const [isScanning, setIsScanning] = useState(false); @@ -384,8 +417,19 @@ export function StellarReceive() { ); if (response.status === 'ERROR') throw new Error('Transaction submission failed'); + const txHashHex = response.hash; + + addActivity({ + id: txHashHex, + chain: 'stellar', + wallet: address, + kind: 'name-registration', + direction: 'out', + status: 'pending', + timestamp: Date.now(), + }); - setRegHash(response.hash); + setRegHash(txHashHex); let attempts = 0; while (attempts < 30) { @@ -398,11 +442,15 @@ export function StellarReceive() { } if (result.status === 'SUCCESS') { setIsRegSuccess(true); + updateActivity(txHashHex, 'confirmed'); + } else if (result.status === 'FAILED') { + updateActivity(txHashHex, 'failed'); } break; } catch (pollErr: unknown) { if (pollErr instanceof Error && pollErr.message?.includes('Bad union switch')) { setIsRegSuccess(true); + updateActivity(txHashHex, 'confirmed'); break; } throw pollErr; @@ -429,10 +477,23 @@ export function StellarReceive() { new URL('../workers/stellar-scanner.worker.ts', import.meta.url), { type: 'module' }, ); - workerRef.current.onmessage = (e) => { if (e.data.type === 'SUCCESS') { - setMatched(e.data.results); + const results = e.data.results; + setMatched(results); + + results.forEach((m: MatchedAnnouncement) => { + addActivity({ + id: m.stealthAddress, // use address as unique id for receives + chain: 'stellar', + wallet: address || '', + kind: 'stealth-receive', + direction: 'in', + status: 'confirmed', // immediately confirmed since it's discovered + timestamp: Date.now(), + }); + }); + setHasScanned(true); setIsScanning(false); } else if (e.data.type === 'ERROR') { diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index 64c8088..47d776d 100644 --- a/src/components/StellarSend.tsx +++ b/src/components/StellarSend.tsx @@ -20,6 +20,7 @@ import { import { useStellarWallet } from '@/context/StellarWalletContext'; import { STELLAR_NETWORK } from '@/config'; import { StellarSendView } from '@/components/StellarSendView'; +import { useActivityStore } from '@/stores/activityStore'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const STELLAR_BASE_FEE_XLM = 0.00001; @@ -75,6 +76,8 @@ export function StellarSend() { const paramExp = searchParams.get('exp'); const { address, isConnected, signTransaction } = useStellarWallet(); + const addActivity = useActivityStore((state) => state.addEntry); + const updateActivity = useActivityStore((state) => state.updateStatus); const [recipient, setRecipient] = useState(paramTo || ''); const [amount, setAmount] = useState(paramAmount || ''); const [memo, setMemo] = useState(paramMemo || ''); @@ -235,6 +238,7 @@ export function StellarSend() { setError(''); setIsPending(true); + let txHashHex = ''; try { const decoded = decodeStealthMetaAddress(metaAddress); @@ -281,6 +285,20 @@ export function StellarSend() { const classicTx = builder.build(); const signedXdr = await signTransaction(classicTx.toXDR()); + txHashHex = classicTx.hash().toString('hex'); + setTxHash(txHashHex); + + addActivity({ + id: txHashHex, + chain: 'stellar', + wallet: address, + kind: 'stealth-send', + direction: 'out', + status: 'pending', + amount: amountValue, + recipient: metaAddress, + timestamp: Date.now(), + }); const submitRes = await fetch(`${horizonUrl}/transactions`, { method: 'POST', @@ -339,8 +357,14 @@ export function StellarSend() { } setIsSuccess(true); + updateActivity(txHashHex, 'confirmed'); } catch (err) { - setError(err instanceof Error ? err.message : 'Transaction failed'); + if (txHashHex) updateActivity(txHashHex, 'failed'); + if (err instanceof Error) { + setError(err.message); + } else { + setError('Transaction failed'); + } } finally { setIsPending(false); } diff --git a/src/pages/History.tsx b/src/pages/History.tsx new file mode 100644 index 0000000..f33bfa2 --- /dev/null +++ b/src/pages/History.tsx @@ -0,0 +1,21 @@ +import { useChain } from '@/context/ChainContext'; +import { StellarHistory } from '@/components/StellarHistory'; + +export default function History() { + const { chain } = useChain(); + + if (chain === 'stellar') return ; + + return ( +
+
+

+ History +

+

+ Transaction history for {chain.charAt(0).toUpperCase() + chain.slice(1)} is not yet implemented. +

+
+
+ ); +} diff --git a/src/stores/activityStore.ts b/src/stores/activityStore.ts new file mode 100644 index 0000000..9279da8 --- /dev/null +++ b/src/stores/activityStore.ts @@ -0,0 +1,82 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { STELLAR_NETWORK } from '@/config'; + +export type ActivityKind = 'stealth-send' | 'stealth-receive' | 'withdrawal' | 'name-registration'; +export type ActivityStatus = 'pending' | 'confirmed' | 'failed'; +export type ActivityDirection = 'in' | 'out'; + +export interface ActivityEntry { + id: string; // usually tx hash + chain: string; // e.g., 'stellar' + wallet: string; // the connected wallet address + kind: ActivityKind; + direction: ActivityDirection; + status: ActivityStatus; + amount?: string; + token?: string; + recipient?: string; + metadata?: any; + timestamp: number; +} + +interface ActivityState { + entries: ActivityEntry[]; + addEntry: (entry: ActivityEntry) => void; + updateStatus: (id: string, status: ActivityStatus) => void; + clearHistory: (chain: string, wallet: string) => void; + pollPending: () => Promise; +} + +export const useActivityStore = create()( + persist( + (set, get) => ({ + entries: [], + addEntry: (entry) => + set((state) => { + // Prevent duplicates by id + const existing = state.entries.find((e) => e.id === entry.id); + if (existing) return state; + return { entries: [entry, ...state.entries] }; + }), + updateStatus: (id, status) => + set((state) => ({ + entries: state.entries.map((e) => (e.id === id ? { ...e, status } : e)), + })), + clearHistory: (chain, wallet) => + set((state) => ({ + entries: state.entries.filter((e) => !(e.chain === chain && e.wallet === wallet)), + })), + pollPending: async () => { + const { entries, updateStatus } = get(); + const pendingTxs = entries.filter((e) => e.status === 'pending' && e.chain === 'stellar'); + + for (const tx of pendingTxs) { + try { + const res = await fetch(`${STELLAR_NETWORK.horizonUrl}/transactions/${tx.id}`); + if (res.ok) { + const data = await res.json(); + if (data.successful) { + updateStatus(tx.id, 'confirmed'); + } else { + updateStatus(tx.id, 'failed'); + } + } else if (res.status === 404) { + // If it's older than 5 minutes and still 404, mark as failed + if (Date.now() - tx.timestamp > 5 * 60 * 1000) { + updateStatus(tx.id, 'failed'); + } + } else { + // Some other error, maybe Horizon is down, don't change status + } + } catch (e) { + // Ignore fetch errors to keep polling next time + } + } + }, + }), + { + name: 'wraith-activity-storage', + } + ) +);