Skip to content
Merged
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: 4 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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"
}
}
52 changes: 34 additions & 18 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<RiskAssessment | null>(null);
const [error, setError] = useState<string | null>(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);
}
Expand All @@ -33,11 +48,21 @@ export function App() {
Destination address
<input
value={address}
onChange={(e) => 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}
/>
</label>
{error && (
<p id="address-error" role="alert" style={{ color: "#c0392b", marginTop: 8 }}>
{error}
</p>
)}

<label style={{ display: "block", marginTop: 12 }}>
Amount (USDT)
Expand All @@ -58,24 +83,15 @@ export function App() {
/>
</label>

<button onClick={handleCheck} disabled={busy || !address} style={{ marginTop: 16, padding: "8px 16px" }}>
<button
onClick={handleCheck}
disabled={busy || !address}
style={{ marginTop: 16, padding: "12px 16px", minHeight: 44, minWidth: 44 }}
>
{busy ? "Analyzing on-device..." : "Check before sending"}
</button>

{assessment && (
<div
style={{
marginTop: 20,
padding: 16,
border: "2px solid",
borderColor: requiresHardBlock(assessment.level) ? "#c0392b" : requiresFriction(assessment.level) ? "#e67e22" : "#27ae60",
}}
>
<strong>Risk level: {assessment.level}</strong>
<p>{assessment.summary}</p>
{requiresHardBlock(assessment.level) && <p>This send would be blocked — WDK signing must not proceed.</p>}
</div>
)}
{assessment && <RiskAssessmentPanel assessment={assessment} />}
</main>
);
}
54 changes: 54 additions & 0 deletions apps/web/src/RiskAssessmentPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
aria-labelledby="risk-heading"
style={{
marginTop: 20,
padding: 16,
border: "2px solid",
borderColor: view.color,
borderRadius: 8,
}}
>
<h2 id="risk-heading" style={{ margin: 0, fontSize: "1.1rem", color: view.color }}>
<span aria-hidden="true" style={{ marginRight: 8 }}>
{view.icon}
</span>
{view.label}
</h2>
<p style={{ marginTop: 8 }}>{view.hint}</p>
{network && <p>Detected network: {networkLabel(network)}</p>}
<p>{assessment.summary}</p>
{assessment.matches.length > 0 && (
<ul style={{ margin: "12px 0 0", paddingLeft: 20 }}>
{assessment.matches.map((match, index) => {
const presented = presentMatch(match);
return (
<li key={`${match.pattern.id}-${index}`} style={{ marginTop: 8 }}>
<strong>{presented.categoryLabel}</strong>
<span> ({presented.confidenceLabel})</span>
{presented.evidence && (
<div style={{ marginTop: 4, color: "#333" }}>
Evidence: "{presented.evidence}"
</div>
)}
</li>
);
})}
</ul>
)}
</section>
);
}
48 changes: 48 additions & 0 deletions apps/web/src/__tests__/riskPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
74 changes: 74 additions & 0 deletions apps/web/src/riskPresentation.ts
Original file line number Diff line number Diff line change
@@ -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, RiskLevelView> = {
[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<ScamCategory, string> = {
"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`,
};
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
31 changes: 31 additions & 0 deletions packages/core/src/domain/services/parseDestinationAddress.ts
Original file line number Diff line number Diff line change
@@ -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" };
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading