diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9474a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +.env.local +*.local diff --git a/frontend/.yarn/install-state.gz b/frontend/.yarn/install-state.gz new file mode 100644 index 0000000..4b043e5 Binary files /dev/null and b/frontend/.yarn/install-state.gz differ diff --git a/frontend/.yarnrc.yml b/frontend/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/frontend/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..63c9225 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + Stealth — Bitcoin Wallet Privacy Analyzer + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..62cfbea --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "stealth-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.7" + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..ea5bd3b --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,77 @@ +/* CSS Custom Properties — Stealth Theme */ +:root { + --bg: #080c14; + --surface: #0f1623; + --surface-2: #162030; + --border: #1e2d45; + --border-hover: #2a3f5e; + --accent: #00d4aa; + --accent-dim: rgba(0, 212, 170, 0.15); + --accent-glow: rgba(0, 212, 170, 0.4); + --danger: #ff4d6d; + --danger-dim: rgba(255, 77, 109, 0.15); + --warning: #f4a261; + --warning-dim: rgba(244, 162, 97, 0.15); + --safe: #2ec4b6; + --safe-dim: rgba(46, 196, 182, 0.15); + --text: #e8edf5; + --text-muted: #6b7a99; + --text-dim: #3d4f6e; + --font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + --font-data: 'JetBrains Mono', 'Fira Code', monospace; + --radius: 8px; + --radius-lg: 12px; + --transition: 0.2s ease; +} + +/* Reset & Base */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; +} + +body { + background-color: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: 16px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#root { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border-hover); +} + +/* Focus visible */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..81fc92d --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,34 @@ +import { useState } from 'react' +import InputScreen from './screens/InputScreen' +import LoadingScreen from './screens/LoadingScreen' +import ReportScreen from './screens/ReportScreen' +import { analyzeWallet } from './services/walletService' + +export default function App() { + const [screen, setScreen] = useState('input') + const [descriptor, setDescriptor] = useState('') + const [report, setReport] = useState(null) + + async function handleAnalyze(desc) { + setDescriptor(desc) + setScreen('loading') + try { + const result = await analyzeWallet(desc) + setReport(result) + setScreen('report') + } catch (err) { + console.error('Analysis failed:', err) + setScreen('input') + } + } + + function handleReset() { + setScreen('input') + setDescriptor('') + setReport(null) + } + + if (screen === 'loading') return + if (screen === 'report') return + return +} diff --git a/frontend/src/components/UtxoCard.jsx b/frontend/src/components/UtxoCard.jsx new file mode 100644 index 0000000..2665174 --- /dev/null +++ b/frontend/src/components/UtxoCard.jsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import VulnerabilityBadge from './VulnerabilityBadge' +import styles from './UtxoCard.module.css' + +function truncateAddress(addr) { + if (!addr || addr.length <= 20) return addr + return `${addr.slice(0, 12)}…${addr.slice(-8)}` +} + +function truncateTxid(txid) { + if (!txid || txid.length <= 24) return txid + return `${txid.slice(0, 16)}…${txid.slice(-8)}` +} + +export default function UtxoCard({ utxo }) { + const [open, setOpen] = useState(false) + const isClean = utxo.vulnerabilities.length === 0 + + const highestSeverity = utxo.vulnerabilities.reduce((acc, v) => { + const order = { high: 3, medium: 2, low: 1 } + return (order[v.severity] ?? 0) > (order[acc] ?? 0) ? v.severity : acc + }, null) + + return ( +
+
setOpen((o) => !o)} + role="button" + aria-expanded={open} + > +
+
+ + {truncateAddress(utxo.address)} + +
+
+ {isClean ? ( + ✓ Clean + ) : ( + utxo.vulnerabilities.map((v, i) => ( + + )) + )} +
+
+ +
+ + {utxo.amountBtc.toFixed(8)} BTC + + + {utxo.confirmations.toLocaleString()} confs + +
+ + +
+ +
+ txid +
+ {utxo.txid}:{utxo.vout} +
+ + {!isClean && ( +
+ {utxo.vulnerabilities.map((v, i) => ( +
+
+ +
+

{v.description}

+
+ ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/UtxoCard.module.css b/frontend/src/components/UtxoCard.module.css new file mode 100644 index 0000000..9e0dbcf --- /dev/null +++ b/frontend/src/components/UtxoCard.module.css @@ -0,0 +1,168 @@ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + transition: border-color var(--transition); +} + +.card:hover { + border-color: var(--border-hover); +} + +.card.hasVulnerabilities { + border-left: 3px solid var(--danger); +} + +.card.clean { + border-left: 3px solid var(--accent); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + cursor: pointer; + gap: 16px; + user-select: none; +} + +.headerLeft { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex: 1; +} + +.addressRow { + display: flex; + align-items: center; + gap: 8px; +} + +.address { + font-family: var(--font-data); + font-size: 14px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.cleanLabel { + font-size: 11px; + font-weight: 600; + color: var(--accent); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.headerRight { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; +} + +.amount { + font-family: var(--font-data); + font-size: 16px; + font-weight: 500; + color: var(--text); +} + +.confirmations { + font-size: 12px; + color: var(--text-muted); +} + +.chevron { + color: var(--text-muted); + font-size: 12px; + transition: transform var(--transition); + flex-shrink: 0; +} + +.chevron.open { + transform: rotate(180deg); +} + +/* Detail panel */ +.detail { + border-top: 1px solid var(--border); + padding: 0; + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease, padding 0.3s ease; +} + +.detail.open { + max-height: 600px; + padding: 16px 20px; +} + +.txid { + font-family: var(--font-data); + font-size: 12px; + color: var(--text-muted); + word-break: break-all; + margin-bottom: 16px; +} + +.txidLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + display: block; + margin-bottom: 4px; +} + +.vulnerabilityList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.vulnItem { + border-radius: var(--radius); + padding: 14px 16px; +} + +.vulnItem.high { + background: var(--danger-dim); + border: 1px solid rgba(255, 77, 109, 0.2); +} + +.vulnItem.medium { + background: var(--warning-dim); + border: 1px solid rgba(244, 162, 97, 0.2); +} + +.vulnItem.low { + background: var(--safe-dim); + border: 1px solid rgba(46, 196, 182, 0.2); +} + +.vulnHeader { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.vulnDesc { + font-size: 13px; + color: var(--text-muted); + line-height: 1.6; +} diff --git a/frontend/src/components/VulnerabilityBadge.jsx b/frontend/src/components/VulnerabilityBadge.jsx new file mode 100644 index 0000000..1b7e6d4 --- /dev/null +++ b/frontend/src/components/VulnerabilityBadge.jsx @@ -0,0 +1,17 @@ +import styles from './VulnerabilityBadge.module.css' + +const LABELS = { + ADDRESS_REUSE: 'Address Reuse', + DUST_SPEND: 'Dust', + CONSOLIDATION: 'Consolidation', + CIOH: 'CIOH', +} + +export default function VulnerabilityBadge({ type, severity }) { + return ( + + + {LABELS[type] ?? type} + + ) +} diff --git a/frontend/src/components/VulnerabilityBadge.module.css b/frontend/src/components/VulnerabilityBadge.module.css new file mode 100644 index 0000000..19d6f07 --- /dev/null +++ b/frontend/src/components/VulnerabilityBadge.module.css @@ -0,0 +1,53 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 20px; + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + white-space: nowrap; +} + +.dot { + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; +} + +.high { + background: var(--danger-dim); + color: var(--danger); + border: 1px solid rgba(255, 77, 109, 0.3); +} + +.high .dot { + background: var(--danger); + box-shadow: 0 0 4px var(--danger); +} + +.medium { + background: var(--warning-dim); + color: var(--warning); + border: 1px solid rgba(244, 162, 97, 0.3); +} + +.medium .dot { + background: var(--warning); + box-shadow: 0 0 4px var(--warning); +} + +.low { + background: var(--safe-dim); + color: var(--safe); + border: 1px solid rgba(46, 196, 182, 0.3); +} + +.low .dot { + background: var(--safe); + box-shadow: 0 0 4px var(--safe); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..a182e41 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './App.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/src/mocks/mockData.js b/frontend/src/mocks/mockData.js new file mode 100644 index 0000000..57d1b26 --- /dev/null +++ b/frontend/src/mocks/mockData.js @@ -0,0 +1,90 @@ +export const mockReport = { + descriptor: 'wpkh([a1b2c3d4/84h/0h/0h]xpub6CatWdiZynkCminahu8Gmr7FAVnQXBTSMaBxn6qmBNkdm9tDkFzWmjmDrLBCQSTa7BHgpEjCXzMTCyDsQLSmcGYJHBB7cTwpqLNRKGP47uw/0/*)#qwer1234', + summary: { + total: 5, + clean: 1, + vulnerable: 4, + }, + utxos: [ + { + txid: '3a7f2b8c1d4e9f0a6b5c2d7e8f3a1b4c9d2e5f0a7b8c1d4e9f2a5b6c3d7e8f1', + vout: 0, + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + amountBtc: 0.05234891, + confirmations: 1842, + vulnerabilities: [], + }, + { + txid: 'b4c8e2f6a1d5b9c3e7f1a5d9b3c7e1f5a9d3b7c1e5f9a3d7b1c5e9f3a7d1b5', + vout: 1, + address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + amountBtc: 0.00023000, + confirmations: 312, + vulnerabilities: [ + { + type: 'DUST_SPEND', + severity: 'medium', + description: + 'This UTXO is near the dust threshold. Spending it may cost more in fees than its value, and dust outputs are often used as tracking vectors by chain surveillance companies.', + }, + { + type: 'ADDRESS_REUSE', + severity: 'high', + description: + 'This address has received funds in 3 separate transactions. Address reuse breaks the one-time-address privacy model and allows observers to link all deposits to the same wallet.', + }, + ], + }, + { + txid: 'f9e3d7c1b5a9f3d7c1b5a9f3d7c1b5a9f3d7c1b5a9f3d7c1b5a9f3d7c1b5a9', + vout: 0, + address: 'bc1q9h7garjcdkl4h5khfz2yxkhsmhep5j7g4cjtch', + amountBtc: 0.12000000, + confirmations: 4521, + vulnerabilities: [ + { + type: 'CONSOLIDATION', + severity: 'medium', + description: + 'This UTXO was created by consolidating 7 inputs in a single transaction. Consolidation reveals that all input addresses belong to the same wallet, reducing privacy significantly.', + }, + ], + }, + { + txid: '2c6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d', + vout: 2, + address: 'bc1qm34mqf4vn8f5vhf0q3djg2zuzfm9aap6e3n4j', + amountBtc: 0.87654321, + confirmations: 98, + vulnerabilities: [ + { + type: 'CIOH', + severity: 'high', + description: + 'Common Input Ownership Heuristic (CIOH): this UTXO was spent alongside UTXOs from different derivation paths in the same transaction, strongly suggesting to analysts that all inputs share a common owner.', + }, + { + type: 'ADDRESS_REUSE', + severity: 'high', + description: + 'This address appears in 5 transactions as both sender and receiver, a pattern that severely compromises wallet privacy and makes cluster analysis trivial.', + }, + ], + }, + { + txid: '7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d', + vout: 0, + address: 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8zeqchgx', + amountBtc: 0.00500000, + confirmations: 2103, + vulnerabilities: [ + { + type: 'DUST_SPEND', + severity: 'low', + description: + 'A small dust amount was received at this address in a prior transaction. While the dust has not been spent, its presence could be used to track this UTXO if included in a future transaction.', + }, + ], + }, + ], +} diff --git a/frontend/src/screens/InputScreen.jsx b/frontend/src/screens/InputScreen.jsx new file mode 100644 index 0000000..b572609 --- /dev/null +++ b/frontend/src/screens/InputScreen.jsx @@ -0,0 +1,54 @@ +import { useState } from 'react' +import styles from './InputScreen.module.css' + +const PLACEHOLDER = `wpkh([a1b2c3d4/84h/0h/0h]xpub6CatWdiZynkCminahu8Gmr7FAVnQXBTSMaBxn6qmBNkdm9tDkFzWmjmDrLBCQSTa7BHgpEjCXzMTCyDsQLSmcGYJHBB7cTwpqLNRKGP47uw/0/*)#qwer1234` + +export default function InputScreen({ onAnalyze }) { + const [descriptor, setDescriptor] = useState('') + + function handleSubmit(e) { + e.preventDefault() + const trimmed = descriptor.trim() + if (!trimmed) return + onAnalyze(trimmed) + } + + return ( +
+
+
+
+ STEALTH +
+
Bitcoin Wallet Privacy Analyzer
+
+ +
+ +