diff --git a/apps/web/package.json b/apps/web/package.json index 96e7dd7..0ec321c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b --noEmit", + "test": "vitest run" }, "dependencies": { "@custos/core": "workspace:*", @@ -21,6 +22,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "typescript": "^5.6.3", - "vite": "^5.4.10" + "vite": "^5.4.10", + "vitest": "^2.1.4" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8f54c0b..dc79390 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,24 +1,39 @@ import { useState } from "react"; import type { RiskAssessment } from "@custos/core"; -import { requiresFriction, requiresHardBlock } from "@custos/core"; +import { parseDestinationAddress } from "@custos/core"; import { analyzeSendIntent } from "./compositionRoot.js"; +import { RiskAssessmentPanel } from "./RiskAssessmentPanel.js"; export function App() { const [address, setAddress] = useState(""); const [amount, setAmount] = useState(""); const [context, setContext] = useState(""); const [assessment, setAssessment] = useState(null); + const [error, setError] = useState(null); const [busy, setBusy] = useState(false); async function handleCheck() { setBusy(true); + setError(null); + setAssessment(null); try { + const parsed = parseDestinationAddress(address); + if (!parsed.ok) { + setError( + parsed.reason === "empty" + ? "Enter a destination address." + : "That is not a Tron (T...) or Ethereum (0x...) address.", + ); + return; + } const result = await analyzeSendIntent.execute({ - destination: { value: address, network: "tron" }, + destination: parsed.address, amountUsdt: Number(amount) || 0, context: context ? { text: context } : undefined, }); setAssessment(result); + } catch (err) { + setError(err instanceof Error ? err.message : "Analysis failed."); } finally { setBusy(false); } @@ -33,11 +48,21 @@ export function App() { Destination address setAddress(e.target.value)} + onChange={(e) => { + setAddress(e.target.value); + setError(null); + }} style={{ width: "100%", padding: 8 }} placeholder="TXyz... / 0x..." + aria-invalid={error !== null} + aria-describedby={error ? "address-error" : undefined} /> + {error && ( + + )} - - {assessment && ( -
- Risk level: {assessment.level} -

{assessment.summary}

- {requiresHardBlock(assessment.level) &&

This send would be blocked — WDK signing must not proceed.

} -
- )} + {assessment && } ); } diff --git a/apps/web/src/RiskAssessmentPanel.tsx b/apps/web/src/RiskAssessmentPanel.tsx new file mode 100644 index 0000000..c2956ef --- /dev/null +++ b/apps/web/src/RiskAssessmentPanel.tsx @@ -0,0 +1,54 @@ +import type { RiskAssessment } from "@custos/core"; +import { presentMatch, presentRiskLevel } from "./riskPresentation.js"; + +function networkLabel(network: string): string { + if (network === "tron") return "Tron"; + if (network === "ethereum") return "Ethereum"; + return network; +} + +export function RiskAssessmentPanel({ assessment }: { assessment: RiskAssessment }) { + const view = presentRiskLevel(assessment.level); + const network = assessment.addressReputation?.address.network; + + return ( +
+

+ + {view.label} +

+

{view.hint}

+ {network &&

Detected network: {networkLabel(network)}

} +

{assessment.summary}

+ {assessment.matches.length > 0 && ( +
    + {assessment.matches.map((match, index) => { + const presented = presentMatch(match); + return ( +
  • + {presented.categoryLabel} + ({presented.confidenceLabel}) + {presented.evidence && ( +
    + Evidence: "{presented.evidence}" +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/src/__tests__/riskPresentation.test.ts b/apps/web/src/__tests__/riskPresentation.test.ts new file mode 100644 index 0000000..4ae6c94 --- /dev/null +++ b/apps/web/src/__tests__/riskPresentation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { RiskLevel } from "@custos/core"; +import type { ScamMatch } from "@custos/core"; +import { presentMatch, presentRiskLevel } from "../riskPresentation.js"; + +describe("presentRiskLevel", () => { + it("maps every RiskLevel to a human label instead of the numeric enum", () => { + expect(presentRiskLevel(RiskLevel.None).label).toBe("No known risk"); + expect(presentRiskLevel(RiskLevel.Low).label).toBe("Low"); + expect(presentRiskLevel(RiskLevel.Elevated).label).toBe("Elevated - review before sending"); + expect(presentRiskLevel(RiskLevel.High).label).toBe("High"); + expect(presentRiskLevel(RiskLevel.Critical).label).toBe("Critical - send blocked"); + }); + + it("never returns the raw enum number as the label", () => { + for (const level of [ + RiskLevel.None, + RiskLevel.Low, + RiskLevel.Elevated, + RiskLevel.High, + RiskLevel.Critical, + ]) { + expect(presentRiskLevel(level).label).not.toBe(String(level)); + expect(presentRiskLevel(level).color).toMatch(/^#/); + } + }); +}); + +describe("presentMatch", () => { + it("renders category and evidence in plain language", () => { + const match: ScamMatch = { + pattern: { + id: "p1", + category: "pig-butchering", + description: "test", + heuristics: [], + }, + confidence: 0.7, + evidenceSnippet: "guaranteed 30% weekly returns", + }; + + expect(presentMatch(match)).toEqual({ + categoryLabel: "Pig-butchering (fake relationship / investment)", + evidence: "guaranteed 30% weekly returns", + confidenceLabel: "70% confidence", + }); + }); +}); diff --git a/apps/web/src/riskPresentation.ts b/apps/web/src/riskPresentation.ts new file mode 100644 index 0000000..711fef4 --- /dev/null +++ b/apps/web/src/riskPresentation.ts @@ -0,0 +1,74 @@ +import { RiskLevel, type ScamCategory, type ScamMatch } from "@custos/core"; + +export interface RiskLevelView { + readonly label: string; + readonly hint: string; + readonly color: string; + readonly icon: string; +} + +export interface MatchView { + readonly categoryLabel: string; + readonly evidence: string | undefined; + readonly confidenceLabel: string; +} + +const RISK_LEVEL_VIEW: Record = { + [RiskLevel.None]: { + label: "No known risk", + hint: "Nothing in the address or chat matched a known scam pattern.", + color: "#1e8449", + icon: "✓", + }, + [RiskLevel.Low]: { + label: "Low", + hint: "Weak signals only. Still worth a second look before you send.", + color: "#2e86c1", + icon: "i", + }, + [RiskLevel.Elevated]: { + label: "Elevated - review before sending", + hint: "This send needs an explicit check. Do not rush.", + color: "#d68910", + icon: "!", + }, + [RiskLevel.High]: { + label: "High", + hint: "Strong scam-pattern match. Only continue if you fully trust the destination.", + color: "#ca6f1e", + icon: "!", + }, + [RiskLevel.Critical]: { + label: "Critical - send blocked", + hint: "Custos will not let this transfer be signed.", + color: "#c0392b", + icon: "✕", + }, +}; + +const SCAM_CATEGORY_LABELS: Record = { + "pig-butchering": "Pig-butchering (fake relationship / investment)", + "fake-support": "Fake wallet or exchange support", + "recovery-scam": "Recovery-agent scam", + "romance-scam": "Romance scam", + "fake-investment-platform": "Fake investment platform", + "urgency-pressure": "Urgency or secrecy pressure", + "address-poisoning": "Address poisoning (lookalike destination)", + "task-scam": "Task / job scam", + "impersonation-authority": "Authority impersonation", + "airdrop-claim-fee": "Airdrop claim-fee trap", + "otc-escrow-scam": "OTC escrow scam", + "phishing-approval-drain": "Phishing approval drain", +}; + +export function presentRiskLevel(level: RiskLevel): RiskLevelView { + return RISK_LEVEL_VIEW[level]; +} + +export function presentMatch(match: ScamMatch): MatchView { + return { + categoryLabel: SCAM_CATEGORY_LABELS[match.pattern.category], + evidence: match.evidenceSnippet, + confidenceLabel: `${Math.round(match.confidence * 100)}% confidence`, + }; +} diff --git a/package.json b/package.json index 4d6c0a1..33bd5d1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "packageManager": "pnpm@9.12.0", "scripts": { "build": "pnpm -r --filter=./packages/** build", - "test": "pnpm -r --filter=./packages/** test", + "test": "pnpm -r --filter=./packages/** --filter=./apps/** test", "lint": "pnpm -r lint", "typecheck": "pnpm -r typecheck", "dev:web": "pnpm --filter @custos/web dev" diff --git a/packages/core/src/domain/services/__tests__/parseDestinationAddress.test.ts b/packages/core/src/domain/services/__tests__/parseDestinationAddress.test.ts new file mode 100644 index 0000000..2b860cc --- /dev/null +++ b/packages/core/src/domain/services/__tests__/parseDestinationAddress.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { parseDestinationAddress } from "../parseDestinationAddress.js"; + +describe("parseDestinationAddress", () => { + it("detects a Tron base58 address", () => { + const result = parseDestinationAddress("TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH"); + + expect(result).toEqual({ + ok: true, + address: { value: "TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH", network: "tron" }, + }); + }); + + it("detects an Ethereum hex address regardless of checksum casing", () => { + const result = parseDestinationAddress("0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6"); + + expect(result).toEqual({ + ok: true, + address: { value: "0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6", network: "ethereum" }, + }); + }); + + it("trims surrounding whitespace before matching", () => { + const result = parseDestinationAddress(" TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH\n"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.address.value).toBe("TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH"); + } + }); + + it("rejects an empty or whitespace-only value", () => { + expect(parseDestinationAddress("")).toEqual({ ok: false, reason: "empty" }); + expect(parseDestinationAddress(" ")).toEqual({ ok: false, reason: "empty" }); + }); + + it("rejects formats that are neither Tron nor Ethereum instead of defaulting to tron", () => { + expect(parseDestinationAddress("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")).toEqual({ + ok: false, + reason: "unrecognized", + }); + expect(parseDestinationAddress("Tshort")).toEqual({ ok: false, reason: "unrecognized" }); + expect(parseDestinationAddress("0xdead")).toEqual({ ok: false, reason: "unrecognized" }); + expect(parseDestinationAddress("742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6")).toEqual({ + ok: false, + reason: "unrecognized", + }); + }); +}); diff --git a/packages/core/src/domain/services/parseDestinationAddress.ts b/packages/core/src/domain/services/parseDestinationAddress.ts new file mode 100644 index 0000000..30b6dfa --- /dev/null +++ b/packages/core/src/domain/services/parseDestinationAddress.ts @@ -0,0 +1,31 @@ +import type { Address } from "../entities/Address.js"; + +/** Tron base58check: 34 characters, T prefix, no 0/O/I/l. */ +const TRON_ADDRESS_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/; + +/** Ethereum account: 0x plus 20 bytes hex. Checksum casing is not required. */ +const ETHEREUM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +export type ParseDestinationAddressResult = + | { readonly ok: true; readonly address: Address } + | { readonly ok: false; readonly reason: "empty" | "unrecognized" }; + +/** + * Infers `ChainNetwork` from the address format so callers never have to + * hardcode `"tron"`. Addresses that match neither Tron nor Ethereum are + * rejected instead of being silently treated as Tron (risk-list keys are + * `network:value`, so a wrong network is a missed lookup). + */ +export function parseDestinationAddress(raw: string): ParseDestinationAddressResult { + const value = raw.trim(); + if (value.length === 0) { + return { ok: false, reason: "empty" }; + } + if (TRON_ADDRESS_RE.test(value)) { + return { ok: true, address: { value, network: "tron" } }; + } + if (ETHEREUM_ADDRESS_RE.test(value)) { + return { ok: true, address: { value, network: "ethereum" } }; + } + return { ok: false, reason: "unrecognized" }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 626d9b0..5811ed0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export * from "./domain/ports/WalletPort.js"; export * from "./domain/ports/RiskListPort.js"; export * from "./domain/ports/AuditLogPort.js"; export * from "./domain/services/detectAddressPoisoning.js"; +export * from "./domain/services/parseDestinationAddress.js"; // Application export * from "./application/use-cases/AnalyzeSendIntent.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae382c8..6c1503e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: vite: specifier: ^5.4.10 version: 5.4.21 + vitest: + specifier: ^2.1.4 + version: 2.1.9 packages/adapters-p2p: dependencies: