From ccc61d663eeb04d488ee86cd80e8b04214731a14 Mon Sep 17 00:00:00 2001 From: LORDBABUINO Date: Fri, 27 Feb 2026 02:00:39 -0300 Subject: [PATCH] Feat: Wire frontend to backend scan endpoint, replace UTXO report with findings - Add GET /api/wallet/scan endpoint that shells out to detect.py - Add CORS config and detect.py script path to application.properties - walletService.js now calls the real scan endpoint instead of mock - Replace UtxoCard-based ReportScreen with FindingCard-based layout - FindingCard: collapsible card with data-driven details panel (address groups, string lists, key-value scalars) - VulnerabilityBadge: all 14 finding types labeled, severity lowercased, critical style added - ReportScreen: summary bar shows findings/warnings/txs analyzed; clean banner; separate warnings section --- backend/requests/wallet.http | 4 + .../stealth/controller/WalletResource.java | 37 ++++ .../src/main/resources/application.properties | 7 + frontend/src/components/FindingCard.jsx | 135 +++++++++++++ .../src/components/FindingCard.module.css | 181 ++++++++++++++++++ .../src/components/VulnerabilityBadge.jsx | 21 +- .../components/VulnerabilityBadge.module.css | 11 ++ frontend/src/screens/ReportScreen.jsx | 65 +++++-- frontend/src/screens/ReportScreen.module.css | 25 ++- frontend/src/services/walletService.js | 14 +- 10 files changed, 461 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/FindingCard.jsx create mode 100644 frontend/src/components/FindingCard.module.css diff --git a/backend/requests/wallet.http b/backend/requests/wallet.http index d489bcd..7926c9c 100644 --- a/backend/requests/wallet.http +++ b/backend/requests/wallet.http @@ -23,6 +23,10 @@ Content-Type: application/json ### Get UTXOs GET {{baseUrl}}/api/wallet/{{analyze.response.body.$.analysisId}}/utxos +### Scan descriptor +# @name scan +GET {{baseUrl}}/api/wallet/scan?descriptor={{descriptor}} + > {% client.test("status is 200", function() { client.assert(response.status === 200, "expected 200"); diff --git a/backend/src/StealthBackend/src/main/java/org/backend/stealth/controller/WalletResource.java b/backend/src/StealthBackend/src/main/java/org/backend/stealth/controller/WalletResource.java index 108f9b6..b10cf0b 100644 --- a/backend/src/StealthBackend/src/main/java/org/backend/stealth/controller/WalletResource.java +++ b/backend/src/StealthBackend/src/main/java/org/backend/stealth/controller/WalletResource.java @@ -1,20 +1,26 @@ package org.backend.stealth.controller; +import jakarta.enterprise.context.ApplicationScoped; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.backend.stealth.mocks.WalletMockData; +import org.eclipse.microprofile.config.inject.ConfigProperty; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +@ApplicationScoped @Path("/api/wallet") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) public class WalletResource { + @ConfigProperty(name = "stealth.detect.script", defaultValue = "../../script/detect.py") + String detectScript; + private static final Map sessions = new ConcurrentHashMap<>(); // DTOs @@ -64,4 +70,35 @@ public class WalletResource { } return Response.ok(WalletMockData.buildReport(descriptor)).build(); } + + @GET + @Path("/scan") + public Response scan(@QueryParam("descriptor") String descriptor) { + if (descriptor == null || descriptor.isBlank()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "descriptor query parameter is required")) + .build(); + } + try { + ProcessBuilder pb = new ProcessBuilder("python3", detectScript, descriptor); + pb.redirectErrorStream(false); + Process process = pb.start(); + + String output = new String(process.getInputStream().readAllBytes()); + int exitCode = process.waitFor(); + + if (exitCode != 0 || output.isBlank()) { + String stderr = new String(process.getErrorStream().readAllBytes()); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", stderr.isBlank() ? "detect.py produced no output" : stderr.strip())) + .build(); + } + + return Response.ok(output).type(MediaType.APPLICATION_JSON).build(); + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", e.getMessage())) + .build(); + } + } } diff --git a/backend/src/StealthBackend/src/main/resources/application.properties b/backend/src/StealthBackend/src/main/resources/application.properties index e69de29..dc4d1c4 100644 --- a/backend/src/StealthBackend/src/main/resources/application.properties +++ b/backend/src/StealthBackend/src/main/resources/application.properties @@ -0,0 +1,7 @@ +quarkus.http.port=8080 +quarkus.http.cors=true +quarkus.http.cors.origins=http://localhost:5173 +quarkus.http.cors.methods=GET,POST,OPTIONS +quarkus.http.cors.headers=Content-Type,Accept + +stealth.detect.script=../../script/detect.py diff --git a/frontend/src/components/FindingCard.jsx b/frontend/src/components/FindingCard.jsx new file mode 100644 index 0000000..8c06503 --- /dev/null +++ b/frontend/src/components/FindingCard.jsx @@ -0,0 +1,135 @@ +import { useState } from 'react' +import styles from './FindingCard.module.css' +import VulnerabilityBadge from './VulnerabilityBadge' + +function TxRow({ txid }) { + if (!txid) return null + return ( +
+ txid + {txid.slice(0, 10)}…{txid.slice(-10)} +
+ ) +} + +function AddressRow({ item }) { + const { address, role, amount_btc, sats, script_type, ours } = item + const tag = role ?? (script_type ? `${script_type}${ours != null ? (ours ? ' ·ours' : ' ·ext') : ''}` : null) + const amount = amount_btc != null ? `${amount_btc} BTC` : sats != null ? `${sats} sats` : null + return ( +
+ {address} + {tag && {tag}} + {amount && {amount}} +
+ ) +} + +function AddrGroup({ label, items }) { + if (!items?.length) return null + return ( +
+
{label}
+ {items.map((item, i) => )} +
+ ) +} + +function StringList({ label, items }) { + if (!items?.length) return null + return ( +
+
{label}
+
    + {items.map((s, i) =>
  • {s}
  • )} +
+
+ ) +} + +function ScalarGroup({ data }) { + const entries = Object.entries(data).filter(([, v]) => typeof v !== 'object') + if (!entries.length) return null + return ( +
+ {entries.map(([k, v]) => ( +
+
{k.replace(/_/g, ' ')}
+
{String(v)}
+
+ ))} +
+ ) +} + +function DetailsPanel({ details }) { + if (!details || !Object.keys(details).length) return null + + const skip = new Set() + const parts = [] + + if (details.txid) { + parts.push() + skip.add('txid') + } + + if (typeof details.address === 'string') { + parts.push( +
+ {details.address} +
+ ) + skip.add('address') + } + + const addrFields = [ + 'our_addresses', 'change_outputs', 'received_outputs', + 'dust_inputs', 'normal_inputs', 'tainted_inputs', 'clean_inputs', 'inputs', + ] + for (const f of addrFields) { + if (Array.isArray(details[f]) && details[f].length) { + parts.push() + skip.add(f) + } + } + + if (details.funding_sources && typeof details.funding_sources === 'object' && !Array.isArray(details.funding_sources)) { + const rows = Object.entries(details.funding_sources).map(([k, v]) => ({ + address: k, + role: Array.isArray(v) ? v.join(', ') : String(v), + })) + parts.push() + skip.add('funding_sources') + } + + for (const f of ['reasons', 'patterns', 'signals', 'script_types']) { + if (Array.isArray(details[f]) && details[f].length) { + parts.push() + skip.add(f) + } + } + + const rest = Object.fromEntries(Object.entries(details).filter(([k]) => !skip.has(k))) + if (Object.keys(rest).length) { + parts.push() + } + + return
{parts}
+} + +export default function FindingCard({ finding }) { + const [open, setOpen] = useState(false) + + return ( +
+ + {open && } +
+ ) +} diff --git a/frontend/src/components/FindingCard.module.css b/frontend/src/components/FindingCard.module.css new file mode 100644 index 0000000..49f36d7 --- /dev/null +++ b/frontend/src/components/FindingCard.module.css @@ -0,0 +1,181 @@ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.header { + width: 100%; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + color: var(--text); + transition: background var(--transition); +} + +.header:hover { + background: var(--surface-2); +} + +.left { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + flex: 1; + min-width: 0; +} + +.description { + font-family: var(--font-ui); + font-size: 13px; + color: var(--text); + line-height: 1.5; + word-break: break-word; +} + +.chevron { + font-size: 18px; + color: var(--text-muted); + transition: transform var(--transition); + flex-shrink: 0; + margin-top: 2px; +} + +.open { + transform: rotate(90deg); +} + +/* Details panel */ +.details { + border-top: 1px solid var(--border); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 14px; + background: var(--bg); +} + +/* Tx row */ +.txRow { + display: flex; + align-items: center; + gap: 8px; +} + +.txLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + flex-shrink: 0; +} + +.txHash { + font-family: var(--font-data); + font-size: 12px; + color: var(--text); +} + +/* Address rows */ +.listGroup { + display: flex; + flex-direction: column; + gap: 5px; +} + +.groupLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 2px; +} + +.addrRow { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.addrHash { + font-family: var(--font-data); + font-size: 11px; + color: var(--text); + word-break: break-all; +} + +.addrTag { + font-size: 11px; + color: var(--accent); + flex-shrink: 0; +} + +.addrAmount { + font-family: var(--font-data); + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} + +/* String list */ +.strList { + list-style: none; + display: flex; + flex-direction: column; + gap: 5px; + padding-left: 14px; +} + +.strList li { + font-size: 12px; + color: var(--text); + line-height: 1.5; + position: relative; +} + +.strList li::before { + content: '—'; + position: absolute; + left: -14px; + color: var(--text-muted); +} + +/* Key-value */ +.kvList { + display: flex; + flex-direction: column; + gap: 5px; +} + +.kvRow { + display: flex; + align-items: baseline; + gap: 10px; +} + +.kvKey { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + min-width: 130px; + flex-shrink: 0; +} + +.kvVal { + font-family: var(--font-data); + font-size: 12px; + color: var(--text); +} diff --git a/frontend/src/components/VulnerabilityBadge.jsx b/frontend/src/components/VulnerabilityBadge.jsx index 1b7e6d4..5d8e1cd 100644 --- a/frontend/src/components/VulnerabilityBadge.jsx +++ b/frontend/src/components/VulnerabilityBadge.jsx @@ -1,15 +1,26 @@ import styles from './VulnerabilityBadge.module.css' const LABELS = { - ADDRESS_REUSE: 'Address Reuse', - DUST_SPEND: 'Dust', - CONSOLIDATION: 'Consolidation', - CIOH: 'CIOH', + ADDRESS_REUSE: 'Address Reuse', + CIOH: 'CIOH', + DUST: 'Dust', + DUST_SPENDING: 'Dust Spend', + CHANGE_DETECTION: 'Change Detect', + CONSOLIDATION: 'Consolidation', + SCRIPT_TYPE_MIXING: 'Script Mixing', + CLUSTER_MERGE: 'Cluster Merge', + UTXO_AGE_SPREAD: 'Age Spread', + EXCHANGE_ORIGIN: 'Exchange Origin', + TAINTED_UTXO_MERGE: 'Taint Merge', + BEHAVIORAL_FINGERPRINT: 'Fingerprint', + DORMANT_UTXOS: 'Dormant UTXOs', + DIRECT_TAINT: 'Direct Taint', } export default function VulnerabilityBadge({ type, severity }) { + const cls = severity ? severity.toLowerCase() : 'low' return ( - + {LABELS[type] ?? type} diff --git a/frontend/src/components/VulnerabilityBadge.module.css b/frontend/src/components/VulnerabilityBadge.module.css index 19d6f07..361f717 100644 --- a/frontend/src/components/VulnerabilityBadge.module.css +++ b/frontend/src/components/VulnerabilityBadge.module.css @@ -51,3 +51,14 @@ background: var(--safe); box-shadow: 0 0 4px var(--safe); } + +.critical { + background: rgba(255, 23, 68, 0.2); + color: #ff1744; + border: 1px solid rgba(255, 23, 68, 0.4); +} + +.critical .dot { + background: #ff1744; + box-shadow: 0 0 6px #ff1744; +} diff --git a/frontend/src/screens/ReportScreen.jsx b/frontend/src/screens/ReportScreen.jsx index ddf7d55..8955c85 100644 --- a/frontend/src/screens/ReportScreen.jsx +++ b/frontend/src/screens/ReportScreen.jsx @@ -1,4 +1,4 @@ -import UtxoCard from '../components/UtxoCard' +import FindingCard from '../components/FindingCard' import styles from './ReportScreen.module.css' function truncateDescriptor(desc) { @@ -7,7 +7,7 @@ function truncateDescriptor(desc) { } export default function ReportScreen({ report, descriptor, onReset }) { - const { summary, utxos } = report + const { stats, findings, warnings, summary } = report return (
@@ -33,29 +33,54 @@ export default function ReportScreen({ report, descriptor, onReset }) { {/* Summary bar */}
-
-
{summary.total}
-
Total UTXOs
-
-
{summary.vulnerable}
-
Vulnerable
+
{summary.findings}
+
Findings
-
-
{summary.clean}
-
Clean
+
+
{summary.warnings}
+
Warnings
+
+
+
{stats.transactions_analyzed}
+
Txs Analyzed
- {/* UTXO list */} -
- UTXO Analysis -
-
- {utxos.map((utxo) => ( - - ))} -
+ {/* Clean banner */} + {summary.clean && ( +
+ No privacy issues found — this wallet has a clean history. +
+ )} + + {/* Findings */} + {findings.length > 0 && ( + <> +
+ Findings +
+
+ {findings.map((f, i) => ( + + ))} +
+ + )} + + {/* Warnings */} + {warnings.length > 0 && ( + <> +
+ Warnings +
+
+ {warnings.map((w, i) => ( + + ))} +
+ + )}
) diff --git a/frontend/src/screens/ReportScreen.module.css b/frontend/src/screens/ReportScreen.module.css index 156f519..fdc4337 100644 --- a/frontend/src/screens/ReportScreen.module.css +++ b/frontend/src/screens/ReportScreen.module.css @@ -129,7 +129,26 @@ border-color: rgba(0, 212, 170, 0.25); } -/* UTXO list */ +.warn .summaryNumber { + color: var(--warning); +} + +.warn { + border-color: rgba(244, 162, 97, 0.25); +} + +/* Clean banner */ +.cleanBanner { + background: var(--accent-dim); + border: 1px solid rgba(0, 212, 170, 0.3); + border-radius: var(--radius); + padding: 14px 18px; + font-size: 13px; + color: var(--accent); + margin-bottom: 28px; +} + +/* Finding list */ .listHeader { display: flex; align-items: center; @@ -145,8 +164,8 @@ color: var(--text-muted); } -.utxoList { +.findingList { display: flex; flex-direction: column; - gap: 10px; + gap: 8px; } diff --git a/frontend/src/services/walletService.js b/frontend/src/services/walletService.js index 7fae2d3..7381368 100644 --- a/frontend/src/services/walletService.js +++ b/frontend/src/services/walletService.js @@ -1,13 +1,5 @@ export const analyzeWallet = async (descriptor) => { - const res1 = await fetch('/api/wallet/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ descriptor }), - }) - if (!res1.ok) throw new Error('Analysis request failed') - const { analysisId } = await res1.json() - - const res2 = await fetch(`/api/wallet/${analysisId}/utxos`) - if (!res2.ok) throw new Error('Failed to fetch report') - return res2.json() + const res = await fetch(`/api/wallet/scan?descriptor=${encodeURIComponent(descriptor)}`) + if (!res.ok) throw new Error('Analysis failed') + return res.json() }