Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"preview": "vite preview",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"prepare": "husky"
},
"dependencies": {
Expand Down Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -13,6 +14,7 @@ export function App() {
<Routes>
<Route path="/send" element={<Send />} />
<Route path="/receive" element={<Receive />} />
<Route path="/history" element={<History />} />
<Route path="*" element={<Navigate to="/send" replace />} />
</Routes>
</main>
Expand Down
1 change: 1 addition & 0 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
23 changes: 21 additions & 2 deletions src/components/HorizenReceive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
Expand Down Expand Up @@ -119,6 +131,13 @@ function StealthRow({
<span className="font-mono text-xs text-outline">...</span>
) : balance && parseFloat(balance) > 0 ? (
<>
<PrivacyBadge
score={computePrivacyScore({
reuseCount: 1,
balance: balance ?? '0',
transferTimestamps: [],
})}
/>
<span className="inline-block h-1.5 w-1.5 bg-tertiary"></span>
<span className="font-heading text-lg font-bold text-on-surface">{balance} ETH</span>
</>
Expand Down
100 changes: 100 additions & 0 deletions src/components/PrivacyBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useState } from 'react';
import type { PrivacyScore } from '@/lib/privacy-score';

const GRADE_STYLES: Record<PrivacyScore['grade'], { dot: string; text: string; label: string }> = {
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 (
<div className="flex items-center gap-2">
<div className="h-1 flex-1 bg-outline-variant">
<div className={`h-full ${color} transition-all`} style={{ width: `${pct}%` }} />
</div>
<span className="w-8 text-right font-mono text-[10px] text-on-surface-variant">{pct}</span>
</div>
);
}

export function PrivacyBadge({ score }: { score: PrivacyScore }) {
const [open, setOpen] = useState(false);
const { dot, text, label } = GRADE_STYLES[score.grade];

return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 transition-opacity hover:opacity-70"
title="Privacy score — click for details"
aria-label={`Privacy score: ${score.score}/100 (${label}). Click for details.`}
>
<span className={`inline-block h-2 w-2 ${dot}`} />
<span className={`font-mono text-[10px] uppercase tracking-widest ${text}`}>{label}</span>
</button>

{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 p-4"
onClick={() => setOpen(false)}
>
<div
className="w-full max-w-sm border border-outline-variant bg-surface-container p-6"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Privacy score breakdown"
>
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 ${dot}`} />
<span className="font-heading text-sm font-bold uppercase tracking-widest text-on-surface">
Privacy Score
</span>
</div>
<div className="flex items-center gap-3">
<span className={`font-mono text-xl font-bold ${text}`}>{score.score}</span>
<button
onClick={() => setOpen(false)}
className="font-mono text-[10px] text-outline transition-colors hover:text-primary"
aria-label="Close"
>
</button>
</div>
</div>

<div className="flex flex-col gap-3">
{(Object.keys(score.factors) as Array<keyof typeof score.factors>).map((key) => (
<div key={key}>
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-[10px] text-on-surface-variant">
{FACTOR_LABELS[key]}
</span>
</div>
<ScoreBar value={score.factors[key]} />
</div>
))}
</div>

<div className="mt-5 border-t border-outline-variant/30 pt-4">
<p className="font-body text-[11px] leading-relaxed text-on-surface-variant">
Computed locally from your scan history. Higher scores mean less on-chain
correlation risk. Sweep funds promptly and avoid reusing addresses to stay private.
</p>
</div>
</div>
</div>
)}
</>
);
}
28 changes: 26 additions & 2 deletions src/components/StellarReceive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -259,6 +276,13 @@ function StellarStealthRow({
<span className="font-mono text-xs text-outline">...</span>
) : balance && parseFloat(balance) > 0 ? (
<>
<PrivacyBadge
score={computePrivacyScore({
reuseCount: 1,
balance: balance ?? '0',
transferTimestamps: [],
})}
/>
<span className="inline-block h-1.5 w-1.5 bg-tertiary"></span>
<span className="font-heading text-lg font-bold text-on-surface">{balance} XLM</span>
</>
Expand Down
41 changes: 41 additions & 0 deletions src/context/ActivityContext.tsx
Original file line number Diff line number Diff line change
@@ -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<ActivityContextValue | null>(null);

export function ActivityProvider({ children }: { children: React.ReactNode }) {
const [addresses, setAddresses] = useState<ScannedAddress[]>([]);

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 (
<ActivityContext.Provider value={{ addresses, upsert }}>{children}</ActivityContext.Provider>
);
}

export function useActivity() {
const ctx = useContext(ActivityContext);
if (!ctx) throw new Error('useActivity must be used within ActivityProvider');
return ctx;
}
Loading