Skip to content
Closed
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
35 changes: 32 additions & 3 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
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() {
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 +47,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 Down Expand Up @@ -72,6 +96,11 @@ export function App() {
}}
>
<strong>Risk level: {assessment.level}</strong>
{assessment.addressReputation && (
<p>
Detected network: {assessment.addressReputation.address.network === "tron" ? "Tron" : "Ethereum"}
</p>
)}
<p>{assessment.summary}</p>
{requiresHardBlock(assessment.level) && <p>This send would be blocked — WDK signing must not proceed.</p>}
</div>
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