diff --git a/package.json b/package.json index 886cfb3..4035456 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "format": "prettier --write .", "format:check": "prettier --check .", + "test": "vitest run", "prepare": "husky" }, "dependencies": { @@ -40,12 +41,15 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.5.0", + "@vitest/ui": "^2.1.9", "autoprefixer": "^10.4.20", "husky": "^9.0.0", + "jsdom": "^29.1.1", "postcss": "^8.5.0", "prettier": "^3.0.0", "tailwindcss": "^3.4.0", "typescript": "^5.7.0", - "vite": "^6.3.0" + "vite": "^6.3.0", + "vitest": "^2.1.9" } } diff --git a/src/App.tsx b/src/App.tsx index 41f52e0..9bd27d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Header } from '@/components/Header'; import { AutoSign } from '@/components/AutoSign'; import Send from '@/pages/Send'; import Receive from '@/pages/Receive'; +import History from '@/pages/History'; export function App() { return ( @@ -13,6 +14,7 @@ export function App() { } /> } /> + } /> } /> diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4ea4696..0e758a8 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -6,6 +6,7 @@ import { WalletConnect } from './WalletConnect'; const navLinks = [ { to: '/send', label: 'Send' }, { to: '/receive', label: 'Receive' }, + { to: '/history', label: 'History' }, ]; export function Header() { diff --git a/src/components/HorizenReceive.tsx b/src/components/HorizenReceive.tsx index 3ac860a..dde1f7f 100644 --- a/src/components/HorizenReceive.tsx +++ b/src/components/HorizenReceive.tsx @@ -23,6 +23,9 @@ import type { HexString, MatchedAnnouncement } from '@wraith-protocol/sdk/chains import { useStealthKeys } from '@/context/StealthKeysContext'; import { CopyButton } from '@/components/CopyButton'; import { horizenTxUrl, horizenAddrUrl } from '@/lib/explorer'; +import { PrivacyBadge } from '@/components/PrivacyBadge'; +import { computePrivacyScore } from '@/lib/privacy-score'; +import { useActivity } from '@/context/ActivityContext'; import { horizenTestnet } from '@/config'; function StealthRow({ @@ -40,19 +43,28 @@ function StealthRow({ const [error, setError] = useState(''); const [showKey, setShowKey] = useState(false); + const { upsert } = useActivity(); + useEffect(() => { (async () => { try { const client = createPublicClient({ chain: horizenTestnet, transport: http() }); const bal = await client.getBalance({ address: match.stealthAddress as `0x${string}` }); - setBalance((Number(bal) / 1e18).toFixed(6)); + const balStr = (Number(bal) / 1e18).toFixed(6); + setBalance(balStr); + upsert({ + address: match.stealthAddress, + chain: 'horizen', + balance: balStr, + scannedAt: Date.now(), + }); } catch { setBalance('0'); } finally { setLoadingBal(false); } })(); - }, [match.stealthAddress]); + }, [match.stealthAddress, upsert]); const handleWithdraw = async () => { if (!dest) return; @@ -119,6 +131,13 @@ function StealthRow({ ... ) : balance && parseFloat(balance) > 0 ? ( <> + {balance} ETH diff --git a/src/components/PrivacyBadge.tsx b/src/components/PrivacyBadge.tsx new file mode 100644 index 0000000..cdd24f6 --- /dev/null +++ b/src/components/PrivacyBadge.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import type { PrivacyScore } from '@/lib/privacy-score'; + +const GRADE_STYLES: Record = { + green: { dot: 'bg-tertiary', text: 'text-tertiary', label: 'Private' }, + yellow: { dot: 'bg-outline', text: 'text-outline', label: 'At Risk' }, + red: { dot: 'bg-error', text: 'text-error', label: 'Exposed' }, +}; + +const FACTOR_LABELS = { + reuse: 'Address reuse', + balance: 'Balance accumulation', + timePattern: 'Transfer timing', +}; + +function ScoreBar({ value }: { value: number }) { + const pct = Math.round(value); + const color = pct >= 75 ? 'bg-tertiary' : pct >= 40 ? 'bg-outline' : 'bg-error'; + return ( +
+
+
+
+ {pct} +
+ ); +} + +export function PrivacyBadge({ score }: { score: PrivacyScore }) { + const [open, setOpen] = useState(false); + const { dot, text, label } = GRADE_STYLES[score.grade]; + + return ( + <> + + + {open && ( +
setOpen(false)} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Privacy score breakdown" + > +
+
+ + + Privacy Score + +
+
+ {score.score} + +
+
+ +
+ {(Object.keys(score.factors) as Array).map((key) => ( +
+
+ + {FACTOR_LABELS[key]} + +
+ +
+ ))} +
+ +
+

+ Computed locally from your scan history. Higher scores mean less on-chain + correlation risk. Sweep funds promptly and avoid reusing addresses to stay private. +

+
+
+
+ )} + + ); +} diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index d279a52..c3d2535 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -21,8 +21,11 @@ import { import type { Announcement, MatchedAnnouncement } from '@wraith-protocol/sdk/chains/stellar'; import { useStealthKeys } from '@/context/StealthKeysContext'; import { useStellarWallet } from '@/context/StellarWalletContext'; +import { useActivity } from '@/context/ActivityContext'; import { CopyButton } from '@/components/CopyButton'; import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; +import { PrivacyBadge } from '@/components/PrivacyBadge'; +import { computePrivacyScore } from '@/lib/privacy-score'; import { STELLAR_NETWORK } from '@/config'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; @@ -152,6 +155,7 @@ function StellarStealthRow({ const [error, setError] = useState(''); const [showKey, setShowKey] = useState(false); + const { upsert } = useActivity(); const scalarHex = match.stealthPrivateScalar.toString(16).padStart(64, '0'); useEffect(() => { @@ -160,18 +164,31 @@ function StellarStealthRow({ const res = await fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${match.stealthAddress}`); if (!res.ok) { setBalance('0'); + upsert({ + address: match.stealthAddress, + chain: 'stellar', + balance: '0', + scannedAt: Date.now(), + }); return; } const data = await res.json(); const xlm = data.balances?.find((b: { asset_type: string }) => b.asset_type === 'native'); - setBalance(xlm?.balance ?? '0'); + const bal = xlm?.balance ?? '0'; + setBalance(bal); + upsert({ + address: match.stealthAddress, + chain: 'stellar', + balance: bal, + scannedAt: Date.now(), + }); } catch { setBalance('0'); } finally { setLoadingBal(false); } })(); - }, [match.stealthAddress]); + }, [match.stealthAddress, upsert]); const handleWithdraw = async () => { if (!dest) return; @@ -259,6 +276,13 @@ function StellarStealthRow({ ... ) : balance && parseFloat(balance) > 0 ? ( <> + {balance} XLM diff --git a/src/context/ActivityContext.tsx b/src/context/ActivityContext.tsx new file mode 100644 index 0000000..630c161 --- /dev/null +++ b/src/context/ActivityContext.tsx @@ -0,0 +1,41 @@ +import { createContext, useContext, useState, useCallback } from 'react'; + +export interface ScannedAddress { + address: string; + chain: string; + balance: string; + scannedAt: number; // ms timestamp +} + +interface ActivityContextValue { + addresses: ScannedAddress[]; + upsert: (entry: ScannedAddress) => void; +} + +const ActivityContext = createContext(null); + +export function ActivityProvider({ children }: { children: React.ReactNode }) { + const [addresses, setAddresses] = useState([]); + + const upsert = useCallback((entry: ScannedAddress) => { + setAddresses((prev) => { + const idx = prev.findIndex((a) => a.address === entry.address); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = entry; + return updated; + } + return [...prev, entry]; + }); + }, []); + + return ( + {children} + ); +} + +export function useActivity() { + const ctx = useContext(ActivityContext); + if (!ctx) throw new Error('useActivity must be used within ActivityProvider'); + return ctx; +} diff --git a/src/lib/privacy-score.test.ts b/src/lib/privacy-score.test.ts new file mode 100644 index 0000000..c8fdf96 --- /dev/null +++ b/src/lib/privacy-score.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { computePrivacyScore, gradeFromScore } from './privacy-score'; + +describe('gradeFromScore', () => { + it('returns green for score >= 75', () => { + expect(gradeFromScore(75)).toBe('green'); + expect(gradeFromScore(100)).toBe('green'); + }); + it('returns yellow for 40–74', () => { + expect(gradeFromScore(40)).toBe('yellow'); + expect(gradeFromScore(74)).toBe('yellow'); + }); + it('returns red for < 40', () => { + expect(gradeFromScore(39)).toBe('red'); + expect(gradeFromScore(0)).toBe('red'); + }); +}); + +describe('computePrivacyScore', () => { + it('gives full score to fresh address with no transfers', () => { + const result = computePrivacyScore({ + reuseCount: 1, + balance: '0', + transferTimestamps: [], + }); + expect(result.score).toBe(100); + expect(result.grade).toBe('green'); + expect(result.factors.reuse).toBe(100); + expect(result.factors.balance).toBe(100); + expect(result.factors.timePattern).toBe(100); + }); + + it('penalises heavy reuse', () => { + const result = computePrivacyScore({ + reuseCount: 5, + balance: '0', + transferTimestamps: [], + }); + expect(result.factors.reuse).toBe(0); + expect(result.score).toBeLessThan(75); + }); + + it('penalises large balance', () => { + const result = computePrivacyScore({ + reuseCount: 1, + balance: '100', + transferTimestamps: [], + }); + expect(result.factors.balance).toBe(20); + // score = 100*0.5 + 20*0.3 + 100*0.2 = 76 — balance is penalised but reuse/time are clean + expect(result.score).toBeLessThan(100); + }); + + it('penalises regular time patterns', () => { + // 5 transfers at perfectly regular 1h intervals + const base = 1_000_000_000; + const hour = 3_600_000; + const timestamps = [0, 1, 2, 3, 4].map((i) => base + i * hour); + const result = computePrivacyScore({ + reuseCount: 1, + balance: '0', + transferTimestamps: timestamps, + }); + expect(result.factors.timePattern).toBe(0); + }); + + it('rewards irregular time patterns', () => { + // Highly irregular gaps → high CV + const timestamps = [0, 100, 10000, 10050, 500000].map((t) => t * 1000); + const result = computePrivacyScore({ + reuseCount: 1, + balance: '0', + transferTimestamps: timestamps, + }); + expect(result.factors.timePattern).toBe(100); + }); + + it('returns red grade for highly reused, funded, regular address', () => { + const base = Date.now(); + const hour = 3_600_000; + const result = computePrivacyScore({ + reuseCount: 5, + balance: '50', + transferTimestamps: [0, 1, 2, 3].map((i) => base + i * hour), + }); + expect(result.grade).toBe('red'); + }); + + it('score is bounded 0–100', () => { + const result = computePrivacyScore({ + reuseCount: 100, + balance: '999', + transferTimestamps: [1, 2, 3, 4].map((i) => i * 1000), + }); + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(100); + }); +}); diff --git a/src/lib/privacy-score.ts b/src/lib/privacy-score.ts new file mode 100644 index 0000000..7eb6761 --- /dev/null +++ b/src/lib/privacy-score.ts @@ -0,0 +1,87 @@ +/** + * Local privacy score for stealth addresses. + * All computation is local — no network calls. + * + * Score: 0–100 (higher = more private) + * Green: 75–100, Yellow: 40–74, Red: 0–39 + */ + +export type PrivacyGrade = 'green' | 'yellow' | 'red'; + +export interface AddressActivity { + /** Number of times this stealth address has appeared in scanned announcements */ + reuseCount: number; + /** Current balance in native units (e.g. "1.5") — empty/zero means less correlation risk */ + balance: string; + /** Unix timestamps (ms) of each inbound transfer */ + transferTimestamps: number[]; +} + +export interface PrivacyScore { + score: number; // 0–100 + grade: PrivacyGrade; + factors: { + reuse: number; // 0–100 (100 = not reused) + balance: number; // 0–100 (100 = empty) + timePattern: number; // 0–100 (100 = unpredictable) + }; +} + +/** Returns 0–100: penalises reuse heavily since it directly links payments */ +function reuseScore(reuseCount: number): number { + if (reuseCount <= 1) return 100; + if (reuseCount === 2) return 60; + if (reuseCount === 3) return 30; + return 0; +} + +/** Returns 0–100: a funded address that has not been swept is a correlation risk */ +function balanceScore(balance: string): number { + const n = parseFloat(balance); + if (!n || n <= 0) return 100; + if (n < 0.001) return 90; + if (n < 1) return 70; + if (n < 10) return 40; + return 20; +} + +/** + * Returns 0–100: regular intervals are detectable on-chain. + * Uses coefficient of variation (stddev / mean) of inter-transfer gaps. + * High variation → unpredictable → private. + */ +function timePatternScore(timestamps: number[]): number { + if (timestamps.length < 2) return 100; + + const sorted = [...timestamps].sort((a, b) => a - b); + const gaps = sorted.slice(1).map((t, i) => t - sorted[i]); + const mean = gaps.reduce((s, g) => s + g, 0) / gaps.length; + if (mean === 0) return 0; + + const variance = gaps.reduce((s, g) => s + (g - mean) ** 2, 0) / gaps.length; + const cv = Math.sqrt(variance) / mean; // coefficient of variation + + // cv >= 1 → highly irregular → score 100; cv = 0 → perfectly regular → score 0 + return Math.min(100, Math.round(cv * 100)); +} + +export function gradeFromScore(score: number): PrivacyGrade { + if (score >= 75) return 'green'; + if (score >= 40) return 'yellow'; + return 'red'; +} + +export function computePrivacyScore(activity: AddressActivity): PrivacyScore { + const reuse = reuseScore(activity.reuseCount); + const balance = balanceScore(activity.balance); + const timePattern = timePatternScore(activity.transferTimestamps); + + // Weighted average: reuse is the most critical factor + const score = Math.round(reuse * 0.5 + balance * 0.3 + timePattern * 0.2); + + return { + score, + grade: gradeFromScore(score), + factors: { reuse, balance, timePattern }, + }; +} diff --git a/src/main.tsx b/src/main.tsx index ee2c8c6..1b257ae 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -18,6 +18,7 @@ import { ccc } from '@ckb-ccc/connector-react'; import { ChainProvider } from '@/context/ChainContext'; import { StealthKeysProvider } from '@/context/StealthKeysContext'; import { StellarWalletProvider } from '@/context/StellarWalletContext'; +import { ActivityProvider } from '@/context/ActivityContext'; import { wagmiConfig } from '@/config'; import { App } from './App'; import '@rainbow-me/rainbowkit/styles.css'; @@ -61,7 +62,9 @@ function Providers({ children }: { children: React.ReactNode }) { > - {children} + + {children} + diff --git a/src/pages/History.tsx b/src/pages/History.tsx new file mode 100644 index 0000000..39cc24e --- /dev/null +++ b/src/pages/History.tsx @@ -0,0 +1,74 @@ +import { useActivity } from '@/context/ActivityContext'; +import { PrivacyBadge } from '@/components/PrivacyBadge'; +import { computePrivacyScore } from '@/lib/privacy-score'; + +export default function History() { + const { addresses } = useActivity(); + + return ( +
+
+ + Local only + +

+ History +

+

+ Stealth addresses detected during this session. Privacy scores are computed locally. +

+
+ + {addresses.length === 0 ? ( +
+

+ No addresses yet +

+

+ Scan for payments on the Receive page to populate history. +

+
+ ) : ( +
+
+ + Address + + + Balance + + + Privacy + +
+ {addresses.map((entry) => { + const score = computePrivacyScore({ + reuseCount: 1, + balance: entry.balance, + transferTimestamps: [], + }); + return ( +
+
+ + {entry.address} + + + {entry.chain} + +
+ + {parseFloat(entry.balance) > 0 ? entry.balance : '—'} + + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/vite.config.ts b/vite.config.ts index 8d96729..34c5527 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,11 @@ -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ + test: { + environment: 'jsdom', + }, plugins: [react()], resolve: { alias: {