diff --git a/README.md b/README.md index 7471363..3cde644 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ Writes need a wallet sign-in when `AUTH_REQUIRED=true` (the default in productio Events must follow the lifecycle: `commissioning → shipping → receiving → storing → selling`, with `shipping` allowed again after receiving or storing. A sold item is final. GTINs are checked against the GS1 check digit, and a token ID or GTIN + serial can only be registered once. +## Clone detection + +Each lookup is scored for the pattern a photocopied label leaves: many different networks scanning one code, or scans in bursts. The product page shows a *Scan risk* meter and flags a likely clone. Try it on the demo item with *Simulate a cloned label*. Details in [backend/README.md](backend/README.md#scan-anomaly-detection). + ## Repository layout | Folder | What it is | diff --git a/backend/README.md b/backend/README.md index f244556..9a9107b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -46,6 +46,15 @@ Set `RPC_URL`, `NFT_CONTRACT_ADDRESS`, `REGISTRY_CONTRACT_ADDRESS` and `RELAYER_ The relayer is a trust assumption: the API decides who may do what, and the contracts enforce ordering and revocation. Try it end to end against a local node with `npm run smoke:chain` (starts Hardhat, deploys, runs a full product life, checks the contracts directly). +## Scan anomaly detection + +Every product lookup counts as a scan. The API logs, per token and for the last 24 hours, which *network* each scan came from (the /24 of the IP, hashed with a server secret; addresses are never stored) and scores the pattern in `services/risk.ts`: + +- **Spread:** a genuine label is read in a few places, a photocopied one shows up everywhere. Distinct networks above 4 (above 2 once the item is sold) raise the score. +- **Bursts:** more than 8 scans inside one minute looks like a script probing the code. + +`score = 100 * (1 - exp(-(0.28 * spread + 0.12 * probing)))`, with 35 and 70 as the medium and high cut-offs. Lookups return `risk: { score, level, reasons, scans, sources }` and the web app shows it on the product page, flagging a likely clone. It is a transparent statistical model, not a trained network, so every reason traces back to a number. The history is in memory (with several API instances, move it to Redis). The in-browser demo mirrors the same function (`frontend/src/lib/risk.ts`); on the demo item, press *Simulate a cloned label*. + ## Logs One JSON line per request (`id`, `method`, `path`, `status`, `ms`, `user`); the id is also returned as `X-Request-Id`. Send your own `X-Request-Id` to trace a call across services. diff --git a/backend/src/app.test.ts b/backend/src/app.test.ts index 4ac6184..c73f4e2 100644 --- a/backend/src/app.test.ts +++ b/backend/src/app.test.ts @@ -56,6 +56,17 @@ describe("open mode (local development)", () => { expect((await api.get("/api/metadata/" + res.body.tokenId)).status).toBe(200); }); + it("scores scan risk on every lookup", async () => { + await api.post("/api/products").send(product()); + const first = await api.get("/api/metadata/0xabc123"); + expect(first.body.risk).toMatchObject({ level: "low", scans: 1, sources: 1 }); + // a script hammering one product trips the burst check + let last = first; + for (let i = 0; i < 25; i++) last = await api.get("/api/metadata/0xabc123"); + expect(last.body.risk.level).not.toBe("low"); + expect(last.body.risk.reasons.join(" ")).toMatch(/within one minute/); + }); + it("refuses to overwrite an existing token", async () => { await api.post("/api/products").send(product()); const res = await api.post("/api/products").send(product({ name: "Fake" })); diff --git a/backend/src/app.ts b/backend/src/app.ts index 0ffc464..e5d2d53 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -17,6 +17,7 @@ import { verifyRouter } from "./routes/verify.js"; import { requestLog } from "./middleware/requestLog.js"; import { Anchor } from "./services/anchor.js"; import { NonceStore } from "./services/nonces.js"; +import { ScanLog } from "./services/risk.js"; import type { Chain } from "./config/blockchain.js"; export function createApp(env: Env, repo: Repository, chain: Chain | null = null) { @@ -28,6 +29,7 @@ export function createApp(env: Env, repo: Repository, chain: Chain | null = null registry: chain?.registry ?? null, anchor: chain?.write ? new Anchor(chain.write) : null, nonces: new NonceStore(), + scans: new ScanLog(), }; // behind nginx / a platform proxy, so client IPs are real for rate limiting diff --git a/backend/src/http.ts b/backend/src/http.ts index 2580407..8cb5111 100644 --- a/backend/src/http.ts +++ b/backend/src/http.ts @@ -5,6 +5,7 @@ import type { Env } from "./config/env.js"; import type { Repository } from "./repo/types.js"; import type { Anchor } from "./services/anchor.js"; import type { NonceStore } from "./services/nonces.js"; +import type { ScanLog } from "./services/risk.js"; export interface Deps { env: Env; @@ -15,6 +16,7 @@ export interface Deps { /** Registry contract for reading stages. */ registry: Contract | null; nonces: NonceStore; + scans: ScanLog; } /** Express 4 does not catch rejected promises from async handlers. */ diff --git a/backend/src/routes/metadata.ts b/backend/src/routes/metadata.ts index b049365..4edf64c 100644 --- a/backend/src/routes/metadata.ts +++ b/backend/src/routes/metadata.ts @@ -10,7 +10,7 @@ export const metadataRouter = (deps: Deps) => { "/:tokenId", wrap(async (req, res) => { const id = normalizeToken(req.params.tokenId); - const record = id && (await loadRecord(deps, id)); + const record = id && (await loadRecord(deps, id, req)); if (!record) throw notFound("Product not found"); res.json(record); }), diff --git a/backend/src/routes/products.ts b/backend/src/routes/products.ts index e20079f..53f2244 100644 --- a/backend/src/routes/products.ts +++ b/backend/src/routes/products.ts @@ -62,7 +62,7 @@ export const productsRouter = (deps: Deps) => { "/:tokenId", wrap(async (req, res) => { const id = normalizeToken(req.params.tokenId); - const record = id && (await loadRecord(deps, id)); + const record = id && (await loadRecord(deps, id, req)); if (!record) throw notFound("Product not found"); res.json(record); }), diff --git a/backend/src/services/records.ts b/backend/src/services/records.ts index f4688fb..aeeaad4 100644 --- a/backend/src/services/records.ts +++ b/backend/src/services/records.ts @@ -1,10 +1,17 @@ +import type { Request } from "express"; import type { Deps } from "../http.js"; import { readOnChainProduct } from "./blockchainService.js"; +import { assessRisk, sourceOf } from "./risk.js"; -/** Product, its events and (when a chain is configured) the on-chain view. Null when unknown. */ -export async function loadRecord({ repo, nft, registry }: Deps, tokenId: string) { +/** + * Product, its events, the on-chain view (when a chain is configured) and a scan + * risk score. Looking a product up counts as a scan. Null when unknown. + */ +export async function loadRecord({ repo, nft, registry, scans, env }: Deps, tokenId: string, req?: Request) { const product = await repo.getProduct(tokenId); if (!product) return null; + if (req) scans.record(tokenId, sourceOf(req.ip, env.JWT_SECRET)); const [events, onChain] = await Promise.all([repo.listEvents(tokenId), readOnChainProduct(nft, tokenId, registry)]); - return { product, events, ...(onChain ? { onChain } : {}) }; + const risk = assessRisk(scans.list(tokenId), product.status); + return { product, events, risk, ...(onChain ? { onChain } : {}) }; } diff --git a/backend/src/services/risk.test.ts b/backend/src/services/risk.test.ts new file mode 100644 index 0000000..7dd1338 --- /dev/null +++ b/backend/src/services/risk.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { ScanLog, WINDOW_MS, assessRisk, sourceOf, type Scan } from "./risk.js"; + +const NOW = 1_700_000_000_000; +const spread = (n: number, gapMs = 120_000): Scan[] => Array.from({ length: n }, (_, i) => ({ t: NOW - i * gapMs, src: `net${i}` })); + +describe("assessRisk", () => { + it("treats a few ordinary scans as low risk", () => { + const r = assessRisk(spread(3), "IN_TRANSIT", NOW); + expect(r).toMatchObject({ level: "low", score: 0, reasons: [] }); + }); + + it("flags a code scanned from many networks, a cloned label's signature", () => { + const r = assessRisk(spread(10), "IN_TRANSIT", NOW); + expect(r.level).toBe("high"); + expect(r.reasons[0]).toMatch(/10 different networks/); + }); + + it("is stricter once the item is sold", () => { + const unsold = assessRisk(spread(5), "RETAIL", NOW); + const sold = assessRisk(spread(5), "SOLD", NOW); + expect(sold.score).toBeGreaterThan(unsold.score); + expect(sold.level).toBe("medium"); + expect(sold.reasons[0]).toMatch(/already been sold/); + }); + + it("flags scripted probing from a single source", () => { + const burst: Scan[] = Array.from({ length: 30 }, (_, i) => ({ t: NOW - i * 1000, src: "bot" })); + const r = assessRisk(burst, "RETAIL", NOW); + expect(r.level).toBe("high"); + expect(r.sources).toBe(1); + expect(r.reasons.join(" ")).toMatch(/within one minute/); + }); + + it("ignores scans older than a day", () => { + const old: Scan[] = Array.from({ length: 20 }, (_, i) => ({ t: NOW - WINDOW_MS - 1000 - i, src: `n${i}` })); + expect(assessRisk(old, "SOLD", NOW)).toMatchObject({ level: "low", scans: 0 }); + }); + + it("scores more spread as riskier, never lower", () => { + const scores = [2, 5, 8, 12, 20].map((n) => assessRisk(spread(n), "RETAIL", NOW).score); + expect([...scores].sort((a, b) => a - b)).toEqual(scores); + }); +}); + +describe("sourceOf", () => { + it("groups one /24 together, separates others, and never exposes the address", () => { + const a = sourceOf("203.0.113.5", "secret-secret-secret"); + expect(sourceOf("203.0.113.200", "secret-secret-secret")).toBe(a); + expect(sourceOf("203.0.114.5", "secret-secret-secret")).not.toBe(a); + expect(a).not.toContain("203"); + expect(sourceOf("::ffff:203.0.113.9", "secret-secret-secret")).toBe(a); + }); +}); + +describe("ScanLog", () => { + it("keeps a bounded rolling window per token", () => { + const log = new ScanLog(5, 2); + for (let i = 0; i < 9; i++) log.record("a", `s${i}`, NOW + i); + expect(log.list("a")).toHaveLength(5); + log.record("b", "x", NOW); + log.record("c", "x", NOW); + expect(log.list("a")).toHaveLength(0); // evicted: only 2 tokens are kept + log.record("c", "y", NOW + WINDOW_MS + 5); + expect(log.list("c")).toHaveLength(1); // the day-old scan dropped out + }); +}); diff --git a/backend/src/services/risk.ts b/backend/src/services/risk.ts new file mode 100644 index 0000000..06f9be7 --- /dev/null +++ b/backend/src/services/risk.ts @@ -0,0 +1,97 @@ +import { createHmac } from "node:crypto"; +import { isIP } from "node:net"; + +/** + * Scan anomaly detection. A genuine label is read by a handful of people in a + * handful of places; a cloned one shows up everywhere at once. Each lookup is + * logged against the token, then scored from how many distinct networks scanned + * it in the last day and how bursty the scanning is. + * + * This is a transparent statistical model (saturating score over two features), + * not a trained network: every reason it gives can be traced to a number. + * Keep it in sync with frontend/src/lib/risk.ts, which powers the demo. + */ + +export const WINDOW_MS = 24 * 60 * 60 * 1000; +const BURST_MS = 60 * 1000; + +export interface Scan { + t: number; + /** Opaque id of the network the scan came from. Never an IP address. */ + src: string; +} + +export type RiskLevel = "low" | "medium" | "high"; + +export interface Risk { + score: number; + level: RiskLevel; + reasons: string[]; + scans: number; + sources: number; +} + +export function assessRisk(scans: Scan[], status: string | undefined, now = Date.now()): Risk { + const recent = scans.filter((s) => now - s.t <= WINDOW_MS).sort((a, b) => a.t - b.t); + const sources = new Set(recent.map((s) => s.src)).size; + + // most scans inside any one-minute window + let burst = 0; + for (let i = 0, j = 0; i < recent.length; i++) { + while (recent[i].t - recent[j].t > BURST_MS) j++; + burst = Math.max(burst, i - j + 1); + } + + // unsold stock passes through many hands; a sold item is checked by its buyer and few others + const sold = status === "SOLD"; + const spread = Math.max(0, sources - (sold ? 2 : 4)); + const probing = Math.max(0, burst - 8); + + const score = Math.round(100 * (1 - Math.exp(-(0.28 * spread + 0.12 * probing)))); + const level: RiskLevel = score >= 70 ? "high" : score >= 35 ? "medium" : "low"; + + const reasons: string[] = []; + if (spread > 0) { + reasons.push( + `${sources} different networks scanned this code in 24 hours${sold ? ", but it has already been sold" : ""}.`, + ); + } + if (probing > 0) reasons.push(`${burst} scans landed within one minute, which looks automated.`); + + return { score, level, reasons, scans: recent.length, sources }; +} + +/** Coarse network id: the /24 for IPv4 (so one office is one source), the /64 for IPv6, hashed with a secret. */ +export function sourceOf(ip: string | undefined, secret: string): string { + const addr = (ip ?? "unknown").replace(/^::ffff:/, ""); + let net = addr; + if (isIP(addr) === 4) net = addr.split(".").slice(0, 3).join("."); + else if (isIP(addr) === 6) net = addr.split(":").slice(0, 4).join(":"); + return createHmac("sha256", secret).update(net).digest("hex").slice(0, 12); +} + +/** + * Rolling per-token scan history, kept in memory (a day of signal does not + * need to survive a restart). With several API instances, back it with Redis. + */ +export class ScanLog { + private byToken = new Map(); + + constructor( + private readonly maxPerToken = 500, + private readonly maxTokens = 10_000, + ) {} + + record(tokenId: string, src: string, now = Date.now()) { + const list = (this.byToken.get(tokenId) ?? []).filter((s) => now - s.t <= WINDOW_MS); + list.push({ t: now, src }); + if (list.length > this.maxPerToken) list.splice(0, list.length - this.maxPerToken); + this.byToken.delete(tokenId); // re-insert so the oldest tokens are evicted first + this.byToken.set(tokenId, list); + if (this.byToken.size > this.maxTokens) this.byToken.delete(this.byToken.keys().next().value as string); + } + + list(tokenId: string): Scan[] { + return this.byToken.get(tokenId) ?? []; + } +} diff --git a/contracts/test/Gas.ts b/contracts/test/Gas.ts new file mode 100644 index 0000000..b201e7a --- /dev/null +++ b/contracts/test/Gas.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { GTIN, deployProtocol } from "./helpers.js"; + +// Measures what each on-chain action costs and fails if one gets noticeably more expensive. +// Run `npm test` and read the table to quote real numbers. +describe("gas costs", () => { + it("stays within budget for the whole product life", async () => { + const { viem, nft, registry, maker, shipper, shop } = await deployProtocol(); + const client = await viem.getPublicClient(); + const used = async (hash: `0x${string}`) => Number((await client.getTransactionReceipt({ hash })).gasUsed); + + const id = await nft.read.tokenIdFor([GTIN, "GAS-1"]); + const rows: Array<[string, number, number]> = []; + const check = async (label: string, hash: `0x${string}`, budget: number) => { + const gas = await used(hash); + rows.push([label, gas, budget]); + assert.ok(gas <= budget, label + " used " + gas + " gas, budget " + budget); + }; + + await check("mint (ERC-721 + metadata)", await nft.write.mintProduct([maker.account.address, GTIN, "GAS-1", 7n], { account: maker.account }), 215_000); + await check("stage 1: Minted", await registry.write.advance([id], { account: maker.account }), 70_000); + await check("stage 2: InDistribution", await registry.write.advance([id], { account: shipper.account }), 52_000); + await check("stage 3: AtRetail", await registry.write.advance([id], { account: shop.account }), 52_000); + await check("stage 4: ConsumerOwned", await registry.write.advance([id], { account: shop.account }), 52_000); + await check("revoke", await nft.write.revokeProduct([id, "recall"], { account: maker.account }), 44_000); + + const total = rows.slice(0, 5).reduce((n, r) => n + r[1], 0); + console.log("\n action gas"); + for (const [label, gas] of rows) console.log(" " + label.padEnd(30) + String(gas).padStart(8)); + console.log(" " + "full life (mint + 4 stages)".padEnd(30) + String(total).padStart(8) + "\n"); + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5b07b0b..4dd04d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,6 +24,7 @@ AuthenTick is a hybrid system. Ownership and authenticity are anchored on a publ - **GTIN validation.** `domain/gtin.ts` checks the GS1 check digit. - **Wallet auth.** The server issues a stateless, HMAC-signed challenge. The wallet signs it, the server recovers the address, looks up the role, and sets a 12 hour JWT in an `httpOnly` cookie (writes also need an anti-CSRF header). Each challenge is accepted once. Writes are role-checked when `AUTH_REQUIRED` is on. - **On-chain anchoring.** With a relayer key configured, minting and each lifecycle event are mirrored to `AuthenTickNFT` and `OwnershipRegistry` before they are recorded off-chain, so the contracts have the final say on duplicates, ordering and revocation. See [backend/README.md](../backend/README.md#on-chain-anchoring). +- **Scan anomaly detection.** Lookups are scored for clone-like patterns (many networks, or bursts) and the result is returned with the record. See [backend/README.md](../backend/README.md#scan-anomaly-detection). - **Ownership proofs.** A per-product challenge is signed by the claimed owner. The server checks the signature and compares the address with the registered owner, or with `ownerOf` on-chain when `RPC_URL` and `NFT_CONTRACT_ADDRESS` are set. ## Frontend (`frontend/`) @@ -37,6 +38,10 @@ React 19 + Vite. If the API cannot be reached, the app switches to an in-browser 3. The record is shown with its provenance timeline and current status. 4. To prove ownership, the wallet signs a challenge and the API checks the signature against the registered owner. +## Gas costs (measured) + +`contracts/test/Gas.ts` measures each action and fails if one gets more expensive: mint about 196k gas, the four stage changes about 64k, 47k, 47k and 47k, revoke about 40k. A product's whole on-chain life is about 400k gas. Only coarse stages go on-chain; EPCIS events stay in the database. + ## GS1 EPCIS Events follow the EPCIS who/what/when/where/why model: product (GTIN + serial), event time, read point (GLN), and business step (`urn:epcglobal:cbv:bizstep:*`). diff --git a/frontend/e2e/journey.spec.ts b/frontend/e2e/journey.spec.ts index 689ce9c..53cb4a1 100644 --- a/frontend/e2e/journey.spec.ts +++ b/frontend/e2e/journey.spec.ts @@ -40,3 +40,17 @@ test("an unknown token says so instead of hanging", async ({ page }) => { await page.goto("/product/0xdoesnotexist"); await expect(page.getByText(/can.t verify this product/i).first()).toBeVisible(); }); + +test("a cloned label is flagged as suspicious, and restarting clears it", async ({ page }) => { + await page.goto(`/product/${DEMO_TOKEN}`); + await expect(page.getByText("Scan risk")).toBeVisible(); + await expect(page.getByText("Normal", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Simulate a cloned label" }).click(); + await expect(page.getByText("Suspicious", { exact: true })).toBeVisible(); + await expect(page.getByText(/different networks scanned this code/)).toBeVisible(); + await expect(page.getByText("Possible clone: check details")).toBeVisible(); + + await page.getByRole("button", { name: "Restart journey" }).click(); + await expect(page.getByText("Normal", { exact: true })).toBeVisible(); +}); diff --git a/frontend/src/lib/mockApi.test.ts b/frontend/src/lib/mockApi.test.ts index 34e0946..231a731 100644 --- a/frontend/src/lib/mockApi.test.ts +++ b/frontend/src/lib/mockApi.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DEMO_TOKEN, isDemoToken, isValidGtin14, mockRequest, resetDemoToken, resetMock } from "./mockApi"; +import { DEMO_TOKEN, isDemoToken, isValidGtin14, mockRequest, resetDemoToken, resetMock, simulateClonedScans } from "./mockApi"; // the mock waits a few hundred ms to feel real; tests do not need that vi.stubGlobal("setTimeout", ((fn: () => void) => { @@ -63,6 +63,21 @@ describe("demo token", () => { }); }); +describe("scan risk", () => { + it("starts normal, turns suspicious after a cloned-label attack, and resets with the journey", async () => { + const first = (await get(DEMO_TOKEN)) as unknown as { risk: { level: string; sources: number } }; + expect(first.risk).toMatchObject({ level: "low", sources: 1 }); + + simulateClonedScans(DEMO_TOKEN); + const after = (await get(DEMO_TOKEN)) as unknown as { risk: { level: string; reasons: string[] } }; + expect(after.risk.level).toBe("high"); + expect(after.risk.reasons[0]).toMatch(/different networks/); + + resetDemoToken(); + expect(((await get(DEMO_TOKEN)) as unknown as { risk: { level: string } }).risk.level).toBe("low"); + }); +}); + describe("token ids", () => { it("are derived from gtin + serial when the form leaves them blank", async () => { const a = (await mint({ tokenId: undefined, serial: "S-1" })) as { tokenId: string }; diff --git a/frontend/src/lib/mockApi.ts b/frontend/src/lib/mockApi.ts index b740411..f5f3b6e 100644 --- a/frontend/src/lib/mockApi.ts +++ b/frontend/src/lib/mockApi.ts @@ -4,6 +4,8 @@ * the real routes and error shapes and persists to localStorage. */ +import { assessRisk, type Scan } from "./risk"; + const FLAG = "authentick.demo"; const STORE = "authentick.mock"; export const DEMO_TOKEN = "0xde70a11ce0000001"; @@ -19,7 +21,7 @@ type Product = { status?: string; }; type Ev = { bizStep: string; readPoint: string; actor?: string; eventTime: string }; -type Store = { products: Record; events: Record }; +type Store = { products: Record; events: Record; scans?: Record }; export const isDemo = () => { try { @@ -167,7 +169,12 @@ export async function mockRequest(method: "GET" | "POST", path: string, rawBo if (method === "GET" && p[0] === "metadata") { const product = s.products[p[1]]; if (!product) throw new Error("Product not found"); - return { product, events: s.events[p[1]] ?? [] } as T; + // a lookup counts as a scan, exactly as on the real API + const scans = ((s.scans ||= {})[p[1]] ||= []); + scans.push({ t: Date.now(), src: "you" }); + if (scans.length > 500) scans.splice(0, scans.length - 500); + save(s); + return { product, events: s.events[p[1]] ?? [], risk: assessRisk(scans, product.status) } as T; } // POST /api/events @@ -221,6 +228,17 @@ export function resetDemoToken() { const fresh = seed(); s.products[DEMO_TOKEN] = fresh.products[DEMO_TOKEN]; s.events[DEMO_TOKEN] = [{ ...fresh.events[DEMO_TOKEN][0], eventTime: new Date().toISOString() }]; + if (s.scans) delete s.scans[DEMO_TOKEN]; + save(s); +} + +/** Demo only: pretend the label was photocopied and scanned from `n` other networks. */ +export function simulateClonedScans(tokenId: string, n = 9) { + const s = load(); + const id = canon(tokenId) as string; + const list = ((s.scans ||= {})[id] ||= []); + const now = Date.now(); + for (let i = 0; i < n; i++) list.push({ t: now - i * 90_000, src: `clone-${i}` }); save(s); } diff --git a/frontend/src/lib/registry.ts b/frontend/src/lib/registry.ts index 00f005b..df514ca 100644 --- a/frontend/src/lib/registry.ts +++ b/frontend/src/lib/registry.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { apiGet } from "@/api/client"; import { bizKey } from "@/lib/format"; import type { ChainEvent } from "@/components/Timeline"; +import type { Risk } from "@/lib/risk"; const KEY = "authentick.recent"; const MAX = 12; @@ -18,6 +19,8 @@ export type ProductRecord = { currentOwner?: string; }; events: ChainEvent[]; + /** How suspicious the scan pattern for this item looks. */ + risk?: Risk; }; const read = (): string[] => { diff --git a/frontend/src/lib/risk.test.ts b/frontend/src/lib/risk.test.ts new file mode 100644 index 0000000..0111b8e --- /dev/null +++ b/frontend/src/lib/risk.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { assessRisk, type Scan } from "./risk"; + +const NOW = 1_700_000_000_000; +const spread = (n: number): Scan[] => Array.from({ length: n }, (_, i) => ({ t: NOW - i * 120_000, src: `net${i}` })); + +// these mirror backend/src/services/risk.test.ts so the demo cannot drift from the API +describe("assessRisk (demo copy)", () => { + it("scores the same cases the same way as the backend", () => { + expect(assessRisk(spread(3), "RETAIL", NOW)).toMatchObject({ level: "low", score: 0 }); + expect(assessRisk(spread(10), "RETAIL", NOW).level).toBe("high"); + expect(assessRisk(spread(5), "SOLD", NOW).level).toBe("medium"); + const burst: Scan[] = Array.from({ length: 30 }, (_, i) => ({ t: NOW - i * 1000, src: "bot" })); + expect(assessRisk(burst, "RETAIL", NOW).level).toBe("high"); + }); +}); diff --git a/frontend/src/lib/risk.ts b/frontend/src/lib/risk.ts new file mode 100644 index 0000000..4e212d2 --- /dev/null +++ b/frontend/src/lib/risk.ts @@ -0,0 +1,53 @@ +/** + * Scan anomaly scoring, mirrored from backend/src/services/risk.ts so the demo + * behaves like the real API. Keep the two in sync (both have tests). + */ + +export const WINDOW_MS = 24 * 60 * 60 * 1000; +const BURST_MS = 60 * 1000; + +export interface Scan { + t: number; + /** Opaque id of the network the scan came from. Never an IP address. */ + src: string; +} + +export type RiskLevel = "low" | "medium" | "high"; + +export interface Risk { + score: number; + level: RiskLevel; + reasons: string[]; + scans: number; + sources: number; +} + +export function assessRisk(scans: Scan[], status: string | undefined, now = Date.now()): Risk { + const recent = scans.filter((s) => now - s.t <= WINDOW_MS).sort((a, b) => a.t - b.t); + const sources = new Set(recent.map((s) => s.src)).size; + + // most scans inside any one-minute window + let burst = 0; + for (let i = 0, j = 0; i < recent.length; i++) { + while (recent[i].t - recent[j].t > BURST_MS) j++; + burst = Math.max(burst, i - j + 1); + } + + // unsold stock passes through many hands; a sold item is checked by its buyer and few others + const sold = status === "SOLD"; + const spread = Math.max(0, sources - (sold ? 2 : 4)); + const probing = Math.max(0, burst - 8); + + const score = Math.round(100 * (1 - Math.exp(-(0.28 * spread + 0.12 * probing)))); + const level: RiskLevel = score >= 70 ? "high" : score >= 35 ? "medium" : "low"; + + const reasons: string[] = []; + if (spread > 0) { + reasons.push( + `${sources} different networks scanned this code in 24 hours${sold ? ", but it has already been sold" : ""}.`, + ); + } + if (probing > 0) reasons.push(`${burst} scans landed within one minute, which looks automated.`); + + return { score, level, reasons, scans: recent.length, sources }; +} diff --git a/frontend/src/pages/ProductDetailsPage.tsx b/frontend/src/pages/ProductDetailsPage.tsx index ac47c57..d86385f 100644 --- a/frontend/src/pages/ProductDetailsPage.tsx +++ b/frontend/src/pages/ProductDetailsPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { motion } from "framer-motion"; -import { ArrowLeft, Boxes, Copy, Fingerprint, History, KeyRound, PackageOpen, RotateCcw, ScanLine, ShoppingBag, Sparkles, Truck, Wallet, type LucideIcon } from "lucide-react"; +import { ArrowLeft, Boxes, Copy, Fingerprint, History, KeyRound, PackageOpen, RotateCcw, ScanLine, ShieldAlert, ShieldCheck, ShoppingBag, Sparkles, Truck, Wallet, type LucideIcon } from "lucide-react"; import { QRCodeSVG } from "qrcode.react"; import { apiGet, apiPost } from "@/api/client"; import { Seal } from "@/components/Seal"; @@ -9,7 +9,7 @@ import { Timeline } from "@/components/Timeline"; import { useToast } from "@/context/ToastContext"; import { useWallet } from "@/context/WalletContext"; import { bizKey, short, statusLabel } from "@/lib/format"; -import { isDemoToken, resetDemoToken } from "@/lib/mockApi"; +import { isDemoToken, resetDemoToken, simulateClonedScans } from "@/lib/mockApi"; import { STAGES, countStages, remember, type ProductRecord } from "@/lib/registry"; type Load = @@ -85,6 +85,11 @@ export default function ProductDetailsPage() { await fetchRecord(); } + async function cloneAttack() { + simulateClonedScans(id); + await fetchRecord(); + } + async function proveOwnership() { const demoItem = isDemoToken(id); if (!account && !demoItem) return connectWallet(); @@ -152,7 +157,7 @@ export default function ProductDetailsPage() { ); } - const { product, events } = load.data; + const { product, events, risk } = load.data; const link = `${window.location.origin}/product/${encodeURIComponent(product.tokenId)}`; const counts = countStages(events); const demoItem = isDemoToken(product.tokenId); @@ -175,7 +180,11 @@ export default function ProductDetailsPage() {
- Verified authentic + {risk?.level === "high" ? ( + Possible clone: check details + ) : ( + Verified authentic + )}

{product.name}

{product.tokenId} @@ -206,6 +215,40 @@ export default function ProductDetailsPage() { + + + )} + + {risk && ( + +

+ {risk.level === "low" ? : } Scan risk + + {risk.level === "low" ? "Normal" : risk.level === "medium" ? "Unusual" : "Suspicious"} + +
+
+
+
+

+ {risk.reasons.length + ? risk.reasons.join(" ") + : `Scanned ${risk.scans} time${risk.scans === 1 ? "" : "s"} from ${risk.sources} network${risk.sources === 1 ? "" : "s"} in the last 24 hours. Nothing unusual.`} +

+ {risk.level !== "low" && ( +

+ A genuine label is read in a few places. A copied one turns up everywhere. Check where you bought this item. +

+ )} )}