From 5254d2804fc6c2279025d33834123b2ff12c707b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Salazar?= Date: Wed, 9 Sep 2026 17:20:22 -0600 Subject: [PATCH 1/2] feat: detect tron and ethereum from destination address format --- apps/web/src/App.tsx | 35 +++++++++++-- .../__tests__/parseDestinationAddress.test.ts | 49 +++++++++++++++++++ .../services/parseDestinationAddress.ts | 31 ++++++++++++ packages/core/src/index.ts | 1 + 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/domain/services/__tests__/parseDestinationAddress.test.ts create mode 100644 packages/core/src/domain/services/parseDestinationAddress.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8f54c0b..c049558 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import type { RiskAssessment } from "@custos/core"; -import { requiresFriction, requiresHardBlock } from "@custos/core"; +import { parseDestinationAddress, requiresFriction, requiresHardBlock } from "@custos/core"; import { analyzeSendIntent } from "./compositionRoot.js"; export function App() { @@ -8,17 +8,31 @@ export function App() { 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 +47,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.addressReputation && ( -

- Detected network: {assessment.addressReputation.address.network === "tron" ? "Tron" : "Ethereum"} -

- )} -

{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/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: