From 4ab3d811cbb99df3cf4ecf58eb6e581c15b52884 Mon Sep 17 00:00:00 2001 From: legend-esc Date: Sun, 30 Aug 2026 17:22:08 +0100 Subject: [PATCH] feat(identity): SEP-1/SEP-12 creator verification and verified badges (#500) Add SEP-compatible creator identity verification: creators prove domain ownership via a stellar.toml (SEP-1) listing their Stellar account + a SIGNING_KEY, and optionally a signed SEP-12 attestation. New lib/identity parses/validates the TOML and verifies ed25519 attestation signatures; useCreatorVerification drives the flow; VerifiedCreatorBadge renders the badge; CreatorVerificationCard lets connected creators verify from their profile. Badge appears on reputation summaries and public creator profiles. Co-Authored-By: Kilo --- .changeset/sep-identity-500.md | 5 + src/components/CreatorVerificationCard.tsx | 108 ++++++++++++ src/components/ReputationSummary.tsx | 8 + src/components/VerifiedCreatorBadge.test.tsx | 51 ++++++ src/components/VerifiedCreatorBadge.tsx | 101 ++++++++++++ src/hooks/useCreatorVerification.test.ts | 74 +++++++++ src/hooks/useCreatorVerification.ts | 119 +++++++++++++ src/lib/identity/index.ts | 4 + src/lib/identity/stellarToml.test.ts | 69 ++++++++ src/lib/identity/stellarToml.ts | 165 +++++++++++++++++++ src/lib/identity/store.ts | 53 ++++++ src/lib/identity/types.ts | 62 +++++++ src/lib/identity/verify.test.ts | 121 ++++++++++++++ src/lib/identity/verify.ts | 147 +++++++++++++++++ src/pages/profile/page.tsx | 9 + 15 files changed, 1096 insertions(+) create mode 100644 .changeset/sep-identity-500.md create mode 100644 src/components/CreatorVerificationCard.tsx create mode 100644 src/components/VerifiedCreatorBadge.test.tsx create mode 100644 src/components/VerifiedCreatorBadge.tsx create mode 100644 src/hooks/useCreatorVerification.test.ts create mode 100644 src/hooks/useCreatorVerification.ts create mode 100644 src/lib/identity/index.ts create mode 100644 src/lib/identity/stellarToml.test.ts create mode 100644 src/lib/identity/stellarToml.ts create mode 100644 src/lib/identity/store.ts create mode 100644 src/lib/identity/types.ts create mode 100644 src/lib/identity/verify.test.ts create mode 100644 src/lib/identity/verify.ts diff --git a/.changeset/sep-identity-500.md b/.changeset/sep-identity-500.md new file mode 100644 index 00000000..ecee38aa --- /dev/null +++ b/.changeset/sep-identity-500.md @@ -0,0 +1,5 @@ +--- +"prompt-hash-stellar": minor +--- + +Add SEP-1/SEP-12 style creator identity verification and a verified creator badge (#500). Creators prove domain ownership by publishing a `stellar.toml` that lists their Stellar account and a `SIGNING_KEY`; a signed SEP-12 attestation confirms their verified identity. A new `lib/identity` module parses/validates the TOML and verifies ed25519 attestation signatures, `useCreatorVerification` drives the flow, `VerifiedCreatorBadge` renders the badge, and `CreatorVerificationCard` lets connected creators verify from their profile. The badge appears on reputation summaries and public creator profiles. diff --git a/src/components/CreatorVerificationCard.tsx b/src/components/CreatorVerificationCard.tsx new file mode 100644 index 00000000..c4bcc407 --- /dev/null +++ b/src/components/CreatorVerificationCard.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { BadgeCheck, Loader2, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { VerifiedCreatorBadge } from "@/components/VerifiedCreatorBadge"; +import { useCreatorVerification } from "@/hooks/useCreatorVerification"; +import { shortenAddress } from "@/lib/utils"; + +/** + * Lets the connected creator verify their identity using SEP-1 / SEP-12: + * they publish a `stellar.toml` on a domain they control that lists their + * Stellar account and a `SIGNING_KEY`, then (optionally) a signed SEP-12 + * attestation. The result is cached locally so the verified badge renders + * across the app. + */ +export function CreatorVerificationCard({ address }: { address: string }) { + const { verification, isLoading, error, verifyDomain } = useCreatorVerification( + address, + ); + const [domain, setDomain] = useState(""); + + const handleVerify = async (event: React.FormEvent) => { + event.preventDefault(); + if (!domain.trim()) return; + await verifyDomain(domain.trim()); + }; + + return ( +
+
+
+ +
+
+

+ Creator identity verification +

+

+ SEP-1 domain proof + SEP-12 signed attestation +

+
+
+ +
+
+ +

+ Verify ownership of a domain to earn a{" "} + Verified creator badge. Host a{" "} + stellar.toml{" "} + at{" "} + + https://your-domain/.well-known/stellar.toml + {" "} + that lists your account{" "} + + {shortenAddress(address)} + + . +

+ +
+ setDomain(event.target.value)} + placeholder="your-domain.com" + aria-label="Creator domain to verify" + className="h-10 flex-1 border-white/10 bg-white/[0.04] text-slate-100" + /> + +
+ + {verification?.status === "verified" && verification.message ? ( +

{verification.message}

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/components/ReputationSummary.tsx b/src/components/ReputationSummary.tsx index 191027b7..fc4878b3 100644 --- a/src/components/ReputationSummary.tsx +++ b/src/components/ReputationSummary.tsx @@ -15,6 +15,8 @@ import { accountAgeInDays, type ReputationBadge, } from "@/lib/reputation/badges"; +import { useCreatorVerification } from "@/hooks/useCreatorVerification"; +import { VerifiedCreatorBadge } from "@/components/VerifiedCreatorBadge"; interface ReputationResponse { accountCreatedAt: string | null; @@ -108,6 +110,11 @@ export function ReputationSummary({ address }: { address: string }) { staleTime: 60_000, }); + const verifiedLinks = reputationQuery.data?.verifiedLinks ?? []; + const { verification } = useCreatorVerification(address, { + externalVerified: verifiedLinks.length > 0, + }); + if (reputationQuery.isLoading) { return (
{badges.length > 0 && (
+ {badges.map((badge) => { const Icon = BADGE_ICONS[badge.key]; return ( diff --git a/src/components/VerifiedCreatorBadge.test.tsx b/src/components/VerifiedCreatorBadge.test.tsx new file mode 100644 index 00000000..302ae552 --- /dev/null +++ b/src/components/VerifiedCreatorBadge.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { VerifiedCreatorBadge } from "@/components/VerifiedCreatorBadge"; +import type { CreatorVerification } from "@/lib/identity"; + +describe("VerifiedCreatorBadge", () => { + it("renders the verified label for a SEP-1 verification", () => { + const verification: CreatorVerification = { + status: "verified", + method: "sep1-toml", + domain: "creator.example", + stellarTomlUrl: "https://creator.example/.well-known/stellar.toml", + }; + render(); + expect(screen.getByText("Verified creator")).toBeInTheDocument(); + }); + + it("renders nothing for an unverified creator", () => { + const verification: CreatorVerification = { status: "unverified" }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing for an error state", () => { + const verification: CreatorVerification = { + status: "error", + message: "bad sig", + }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows a pending chip for pending verification", () => { + const verification: CreatorVerification = { status: "pending" }; + render(); + expect(screen.getByText("Verification pending")).toBeInTheDocument(); + }); + + it("omits the label in compact mode", () => { + const verification: CreatorVerification = { + status: "verified", + method: "sep12-attestation", + name: "Ada", + }; + const { container } = render( + , + ); + expect(screen.queryByText("Verified creator")).not.toBeInTheDocument(); + expect(container.querySelector("span[role='status']")).toBeInTheDocument(); + }); +}); diff --git a/src/components/VerifiedCreatorBadge.tsx b/src/components/VerifiedCreatorBadge.tsx new file mode 100644 index 00000000..4554623b --- /dev/null +++ b/src/components/VerifiedCreatorBadge.tsx @@ -0,0 +1,101 @@ +import { BadgeCheck } from "lucide-react"; +import { Tooltip } from "@/components/ui/Tooltip"; +import { cn } from "@/lib/utils"; +import type { CreatorVerification, VerificationMethod } from "@/lib/identity"; + +const METHOD_LABEL: Record = { + "sep1-toml": "SEP-1 domain identity", + "sep12-attestation": "SEP-12 verified identity", + "external-link": "Externally verified link", +}; + +interface VerifiedCreatorBadgeProps { + verification?: CreatorVerification | null; + variant?: "compact" | "full"; + className?: string; +} + +/** + * Renders a verified-creator badge driven by SEP-1/SEP-12 verification state. + * Shows nothing for unverified/error states; a muted pending chip for `pending`. + */ +export function VerifiedCreatorBadge({ + verification, + variant = "full", + className, +}: VerifiedCreatorBadgeProps) { + if (!verification || verification.status === "unverified") return null; + + if (verification.status === "pending") { + return ( + + + ); + } + + if (verification.status === "error") return null; + + const methodLabel = verification.method + ? METHOD_LABEL[verification.method] + : "Verified creator"; + const detailLines = [ + verification.name ? `Name: ${verification.name}` : null, + verification.domain ? `Domain: ${verification.domain}` : null, + verification.issuedAt + ? `Issued: ${new Date(verification.issuedAt).toLocaleDateString()}` + : null, + ].filter(Boolean) as string[]; + + const badgeContent = ( + + + ); + + return ( + + {methodLabel} + {detailLines.map((line) => ( + + {line} + + ))} + {verification.stellarTomlUrl ? ( + + View stellar.toml + + ) : null} + + } + > + + {badgeContent} + + + ); +} diff --git a/src/hooks/useCreatorVerification.test.ts b/src/hooks/useCreatorVerification.test.ts new file mode 100644 index 00000000..710d72bf --- /dev/null +++ b/src/hooks/useCreatorVerification.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useCreatorVerification } from "@/hooks/useCreatorVerification"; +import { fetchStellarToml } from "@/lib/identity/stellarToml"; + +const CREATOR = "GCREATOREXAMPLECREATOREXAMPLECREATOREXAMPLECREATOREXAMPLECREATORX"; + +const TOML = `SIGNING_KEY = "GORGSIGNINGKEYORGSIGNINGKEYORGSIGNINGKEYORGSIGNINGKEYORGSIGN" +ACCOUNTS = ["${CREATOR}"] +`; + +describe("useCreatorVerification", () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("verifies a creator domain via SEP-1 stellar.toml", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL) => { + expect(String(input)).toContain("/.well-known/stellar.toml"); + return { + ok: true, + status: 200, + text: async () => TOML, + } as Response; + }), + ); + + const { result } = renderHook(() => useCreatorVerification(CREATOR)); + + expect(result.current.verification).toBeNull(); + + await act(async () => { + await result.current.verifyDomain("creator.example"); + }); + + await waitFor(() => { + expect(result.current.verification?.status).toBe("verified"); + }); + expect(result.current.verification?.method).toBe("sep1-toml"); + }); + + it("reports unverified when the account is missing from the toml", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => + `ACCOUNTS = ["GOTHEROTHEROTHEROTHEROTHEROTHEROTHEROTHEROTHER"]`, + })) as typeof fetch, + ); + + const { result } = renderHook(() => + useCreatorVerification("GABSENTABSENTABSENTABSENTABSENTABSENTABSENT"), + ); + + await act(async () => { + await result.current.verifyDomain("creator.example"); + }); + + await waitFor(() => { + expect(result.current.verification?.status).toBe("unverified"); + }); + }); + + it("rejects non-HTTPS stellar.toml URLs", async () => { + await expect(fetchStellarToml("http://insecure.example/.well-known/stellar.toml")).rejects.toThrow( + /HTTPS/, + ); + }); +}); diff --git a/src/hooks/useCreatorVerification.ts b/src/hooks/useCreatorVerification.ts new file mode 100644 index 00000000..f764451e --- /dev/null +++ b/src/hooks/useCreatorVerification.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useState } from "react"; +import { + evaluateCreatorVerification, + fetchStellarToml, + type CreatorVerification, +} from "@/lib/identity"; +import { + clearVerification, + getStoredVerification, + storeVerification, +} from "@/lib/identity/store"; + +function tomlUrlForDomain(domain: string): string { + const cleaned = domain + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); + return `https://${cleaned}/.well-known/stellar.toml`; +} + +export interface UseCreatorVerificationOptions { + /** Optional pre-known stellar.toml URL for this creator (e.g. from the API). */ + stellarTomlUrl?: string; + /** Whether the reputation service already confirmed an external link. */ + externalVerified?: boolean; +} + +export interface UseCreatorVerificationResult { + verification: CreatorVerification | null; + isLoading: boolean; + error: string | null; + /** Run SEP-1/SEP-12 verification against a creator-controlled domain. */ + verifyDomain: (domain: string) => Promise; + reset: () => void; +} + +export function useCreatorVerification( + address: string | undefined, + options: UseCreatorVerificationOptions = {}, +): UseCreatorVerificationResult { + const { stellarTomlUrl, externalVerified } = options; + const [verification, setVerification] = useState( + address ? getStoredVerification(address) : null, + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!address) { + setVerification(null); + return; + } + const stored = getStoredVerification(address); + if (stored) { + setVerification(stored); + return; + } + if (externalVerified) { + setVerification({ + status: "verified", + method: "external-link", + message: "Verified via an externally confirmed creator link.", + }); + } + }, [address, externalVerified]); + + const verifyDomain = useCallback( + async (domain: string): Promise => { + if (!address) { + const missing: CreatorVerification = { + status: "error", + message: "Connect a wallet to verify your creator identity.", + }; + setError(missing.message ?? "Unable to verify."); + setVerification(missing); + return missing; + } + + setIsLoading(true); + setError(null); + try { + const url = stellarTomlUrl ?? tomlUrlForDomain(domain); + const toml = await fetchStellarToml(url); + const result = evaluateCreatorVerification({ + account: address, + toml, + stellarTomlUrl: url, + externalVerified, + }); + if (result.status === "verified") { + storeVerification(address, result); + } + setVerification(result); + if (result.status === "error" || result.status === "unverified") { + setError(result.message ?? "Verification failed."); + } + return result; + } catch (err) { + const message = + err instanceof Error ? err.message : "Could not verify creator identity."; + const failed: CreatorVerification = { status: "error", message }; + setError(message); + setVerification(failed); + return failed; + } finally { + setIsLoading(false); + } + }, + [address, stellarTomlUrl, externalVerified], + ); + + const reset = useCallback(() => { + if (address) clearVerification(address); + setVerification(null); + setError(null); + }, [address]); + + return { verification, isLoading, error, verifyDomain, reset }; +} diff --git a/src/lib/identity/index.ts b/src/lib/identity/index.ts new file mode 100644 index 00000000..46eb20f9 --- /dev/null +++ b/src/lib/identity/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./stellarToml"; +export * from "./verify"; +export * from "./store"; diff --git a/src/lib/identity/stellarToml.test.ts b/src/lib/identity/stellarToml.test.ts new file mode 100644 index 00000000..7ff6f2cd --- /dev/null +++ b/src/lib/identity/stellarToml.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + findCreatorInToml, + parseStellarToml, + toStellarToml, +} from "@/lib/identity/stellarToml"; + +const SAMPLE_TOML = `# Stellar info file +SIGNING_KEY = "GAIGZHHWYKOAPIXKX3TQOLJLFWHYYPVFRXYBL7GBRL3QSEMEVOVQXXDB" +ACCOUNTS = ["GCREATOREXAMPLECREATOREXAMPLECREATOREXAMPLECREATOREXAMPLECREATORX"] +VERSION = "0.1.0" + +[[VERIFIED_CREATORS]] +ACCOUNT = "GOTHERCREATOROTHERCREATOROTHERCREATOROTHERCREATOROTHERCREATOROTH" +NAME = "Ada Lovelace" +HANDLE = "ada" + +[[DOCUMENTATION]] +ORG_NAME = "Prompt Mint" +`; + +describe("parseStellarToml", () => { + it("parses top-level scalars, arrays, and arrays of tables", () => { + const parsed = parseStellarToml(SAMPLE_TOML); + expect(parsed.SIGNING_KEY).toBe( + "GAIGZHHWYKOAPIXKX3TQOLJLFWHYYPVFRXYBL7GBRL3QSEMEVOVQXXDB", + ); + expect(Array.isArray(parsed.ACCOUNTS)).toBe(true); + expect(parsed.VERSION).toBe("0.1.0"); + expect(Array.isArray(parsed.VERIFIED_CREATORS)).toBe(true); + }); + + it("normalizes to a StellarToml with verified creators", () => { + const toml = toStellarToml(parseStellarToml(SAMPLE_TOML)); + expect(toml.signingKey).toBe( + "GAIGZHHWYKOAPIXKX3TQOLJLFWHYYPVFRXYBL7GBRL3QSEMEVOVQXXDB", + ); + expect(toml.accounts).toContain( + "GCREATOREXAMPLECREATOREXAMPLECREATOREXAMPLECREATOREXAMPLECREATORX", + ); + expect(toml.verifiedCreators[0]).toMatchObject({ + account: "GOTHERCREATOROTHERCREATOROTHERCREATOROTHERCREATOROTHERCREATOROTH", + name: "Ada Lovelace", + }); + }); +}); + +describe("findCreatorInToml", () => { + const toml = toStellarToml(parseStellarToml(SAMPLE_TOML)); + + it("matches an account declared in ACCOUNTS (case-insensitive)", () => { + const lower = "gcreatorexamplecreatorexamplecreatorexamplecreatorexamplecreatorx"; + expect(findCreatorInToml(toml, lower).found).toBe(true); + }); + + it("matches an account in VERIFIED_CREATORS and returns its name", () => { + const other = + "GOTHERCREATOROTHERCREATOROTHERCREATOROTHERCREATOROTHERCREATOROTH"; + const match = findCreatorInToml(toml, other); + expect(match.found).toBe(true); + expect(match.name).toBe("Ada Lovelace"); + }); + + it("returns not found for an unrelated account", () => { + expect( + findCreatorInToml(toml, "GUNRELATEDUNRELATEDUNRELATEDUNRELATEDUNRELATEDUNRELATED").found, + ).toBe(false); + }); +}); diff --git a/src/lib/identity/stellarToml.ts b/src/lib/identity/stellarToml.ts new file mode 100644 index 00000000..f11c7105 --- /dev/null +++ b/src/lib/identity/stellarToml.ts @@ -0,0 +1,165 @@ +import type { StellarToml } from "./types"; + +/** + * Minimal TOML parser covering the subset used by SEP-1 `stellar.toml` + * files: top-level key/value pairs, `[table]` sections, `[[array-of-tables]]` + * sections, inline arrays of scalars, strings, integers, floats and booleans. + * It intentionally does not implement every TOML feature — only what creator + * identity verification needs. + */ +export function parseStellarToml(text: string): Record { + const root: Record = {}; + let current: Record = root; + + const stripComment = (line: string): string => { + let inString = false; + let quote = ""; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inString) { + if (ch === quote) inString = false; + } else if (ch === '"' || ch === "'") { + inString = true; + quote = ch; + } else if (ch === "#") { + return line.slice(0, i); + } + } + return line; + }; + + const parseScalar = (raw: string): unknown => { + const value = raw.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + if (value === "true") return true; + if (value === "false") return false; + if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1).trim(); + if (inner === "") return []; + return inner.split(",").map((part) => parseScalar(part)); + } + if (/^-?\d+$/.test(value)) return Number(value); + if (/^-?\d*\.\d+$/.test(value)) return Number(value); + return value; + }; + + const assign = (key: string, value: unknown) => { + current[key] = value; + }; + + for (const rawLine of text.split(/\r?\n/)) { + const line = stripComment(rawLine).trim(); + if (line === "") continue; + + if (line.startsWith("[[")) { + const name = line.slice(2, line.indexOf("]")).trim(); + const parent: Record = current; + const list = (Array.isArray(parent[name]) ? parent[name] : []) as unknown[]; + const next: Record = {}; + list.push(next); + parent[name] = list; + current = next; + continue; + } + + if (line.startsWith("[")) { + const name = line.slice(1, line.indexOf("]")).trim(); + const segments = name.split("."); + let node: Record = root; + for (const segment of segments) { + if (typeof node[segment] !== "object" || node[segment] === null) { + node[segment] = {}; + } + node = node[segment] as Record; + } + current = node; + continue; + } + + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + const value = line.slice(eq + 1); + assign(key, parseScalar(value)); + } + + return root; +} + +function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((item) => (typeof item === "string" ? item : String(item))) + .filter(Boolean); + } + if (typeof value === "string" && value.length > 0) return [value]; + return []; +} + +/** Normalize a parsed TOML document into the subset we consume. */ +export function toStellarToml(parsed: Record): StellarToml { + const verifiedCreatorsRaw = parsed.VERIFIED_CREATORS; + const verifiedCreators = Array.isArray(verifiedCreatorsRaw) + ? (verifiedCreatorsRaw as Array>).map((entry) => ({ + account: typeof entry.ACCOUNT === "string" ? entry.ACCOUNT : undefined, + name: typeof entry.NAME === "string" ? entry.NAME : undefined, + handle: typeof entry.HANDLE === "string" ? entry.HANDLE : undefined, + })) + : []; + + return { + signingKey: + typeof parsed.SIGNING_KEY === "string" ? parsed.SIGNING_KEY : undefined, + accounts: asStringArray(parsed.ACCOUNTS), + verifiedCreators, + ...parsed, + }; +} + +export interface TomlAccountMatch { + found: boolean; + name?: string; + handle?: string; +} + +/** Check whether a creator account is declared in a parsed `stellar.toml`. */ +export function findCreatorInToml( + toml: StellarToml, + account: string, +): TomlAccountMatch { + const normalized = account.trim().toLowerCase(); + if (toml.accounts.some((a) => a.trim().toLowerCase() === normalized)) { + return { found: true }; + } + for (const creator of toml.verifiedCreators) { + if (creator.account && creator.account.trim().toLowerCase() === normalized) { + return { found: true, name: creator.name, handle: creator.handle }; + } + } + return { found: false }; +} + +/** Fetch and parse a SEP-1 `stellar.toml`. Requires an HTTPS origin. */ +export async function fetchStellarToml(url: string): Promise { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new Error("Invalid stellar.toml URL."); + } + if (parsedUrl.protocol !== "https:") { + throw new Error("stellar.toml must be served over HTTPS."); + } + + const response = await fetch(url, { redirect: "error" }); + if (!response.ok) { + throw new Error(`Could not fetch stellar.toml (${response.status}).`); + } + const text = await response.text(); + return toStellarToml(parseStellarToml(text)); +} diff --git a/src/lib/identity/store.ts b/src/lib/identity/store.ts new file mode 100644 index 00000000..ef1e47c5 --- /dev/null +++ b/src/lib/identity/store.ts @@ -0,0 +1,53 @@ +import type { CreatorVerification } from "./types"; + +/** + * Local persistence of verification claims keyed by creator address. SEP-1/SEP-12 + * verification is fully reproducible from a creator's `stellar.toml`, so this + * store only caches the result so the verified badge renders consistently across + * the app on the viewer's device. A backend-indexed claim is a follow-up. + */ + +const STORAGE_KEY = "prompthash:creator-verifications"; + +type VerificationMap = Record; + +function readMap(): VerificationMap { + if (typeof localStorage === "undefined") return {}; + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as VerificationMap) : {}; + } catch { + return {}; + } +} + +function writeMap(map: VerificationMap): void { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } catch { + // ignore quota / serialization errors — verification is best-effort cache + } +} + +export function getStoredVerification(address: string): CreatorVerification | null { + const normalized = address.trim().toLowerCase(); + return readMap()[normalized] ?? null; +} + +export function storeVerification( + address: string, + verification: CreatorVerification, +): void { + const normalized = address.trim().toLowerCase(); + const map = readMap(); + map[normalized] = verification; + writeMap(map); +} + +export function clearVerification(address: string): void { + const normalized = address.trim().toLowerCase(); + const map = readMap(); + delete map[normalized]; + writeMap(map); +} diff --git a/src/lib/identity/types.ts b/src/lib/identity/types.ts new file mode 100644 index 00000000..7a867153 --- /dev/null +++ b/src/lib/identity/types.ts @@ -0,0 +1,62 @@ +/** + * SEP-compatible creator identity & verification types. + * + * The design follows the Stellar Ecosystem Proposal patterns used across the + * network: + * - SEP-1 (Stellar Info File): a creator proves control of a domain by + * publishing a `stellar.toml` that lists their Stellar account and a + * `SIGNING_KEY` used to attest identity. + * - SEP-12 (KYC / identity): an attestation object (mirroring a SEP-12 + * customer record) signed by that `SIGNING_KEY` confirms the creator's + * verified legal/display identity. + */ + +export type VerificationStatus = + | "verified" + | "unverified" + | "pending" + | "error"; + +export type VerificationMethod = + | "sep1-toml" + | "sep12-attestation" + | "external-link"; + +export interface CreatorVerification { + status: VerificationStatus; + method?: VerificationMethod; + domain?: string; + name?: string; + identityType?: "individual" | "organization"; + issuedAt?: string; + stellarTomlUrl?: string; + attestationUrl?: string; + message?: string; +} + +/** Parsed subset of a SEP-1 `stellar.toml` we rely on. */ +export interface StellarToml { + signingKey?: string; + accounts: string[]; + /** Custom array-of-tables advertising verified creators. */ + verifiedCreators: Array<{ + account?: string; + name?: string; + handle?: string; + }>; + /** Arbitrary extra keys, preserved for debugging/display. */ + [key: string]: unknown; +} + +/** SEP-12-style signed identity attestation for a creator. */ +export interface Sep12Attestation { + schema: "sep12-creator-v1"; + domain: string; + account: string; + name: string; + type: "individual" | "organization"; + status: "VERIFIED" | "PENDING" | "REJECTED"; + issuedAt: string; + attestationUrl?: string; + signature: string; +} diff --git a/src/lib/identity/verify.test.ts b/src/lib/identity/verify.test.ts new file mode 100644 index 00000000..72979e7a --- /dev/null +++ b/src/lib/identity/verify.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { Keypair } from "@stellar/stellar-base"; +import { + buildAttestationPayload, + evaluateCreatorVerification, + verifySep12Attestation, + verifySep1Identity, +} from "@/lib/identity/verify"; +import { toStellarToml, parseStellarToml } from "@/lib/identity/stellarToml"; +import type { Sep12Attestation, StellarToml } from "@/lib/identity/types"; + +function signAttestation( + attestation: Sep12Attestation, + signingKey: Keypair, +): Sep12Attestation { + const payload = buildAttestationPayload(attestation); + const signature = signingKey.sign(Buffer.from(payload, "utf8")).toString("base64"); + return { ...attestation, signature }; +} + +const CREATOR = "GCREATOREXAMPLECREATOREXAMPLECREATOREXAMPLECREATOREXAMPLECREATORX"; + +const TOML_BASE: StellarToml = toStellarToml( + parseStellarToml( + `SIGNING_KEY = "GORGSIGNINGKEYORGSIGNINGKEYORGSIGNINGKEYORGSIGNINGKEYORGSIGN" +ACCOUNTS = ["${CREATOR}"]`, + ), +); + +describe("verifySep1Identity", () => { + it("is true when the creator account is listed", () => { + expect( + verifySep1Identity({ account: CREATOR, toml: TOML_BASE }), + ).toBe(true); + }); + + it("is false when the creator account is absent", () => { + expect( + verifySep1Identity({ + account: "GABSENTABSENTABSENTABSENTABSENTABSENTABSENT", + toml: TOML_BASE, + }), + ).toBe(false); + }); +}); + +describe("verifySep12Attestation", () => { + const orgKey = Keypair.random(); + const tomlWithKey: StellarToml = { + ...TOML_BASE, + signingKey: orgKey.publicKey(), + }; + + const baseAttestation: Sep12Attestation = { + schema: "sep12-creator-v1", + domain: "creator.example", + account: CREATOR, + name: "Ada Lovelace", + type: "individual", + status: "VERIFIED", + issuedAt: "2026-08-30T00:00:00.000Z", + signature: "", + }; + + it("verifies a valid signature from the published SIGNING_KEY", () => { + const signed = signAttestation(baseAttestation, orgKey); + expect(verifySep12Attestation({ attestation: signed, signingKey: orgKey.publicKey() })).toBe( + true, + ); + }); + + it("rejects a signature from a different key", () => { + const signed = signAttestation(baseAttestation, Keypair.random()); + expect(verifySep12Attestation({ attestation: signed, signingKey: orgKey.publicKey() })).toBe( + false, + ); + }); + + it("rejects when the status is not VERIFIED", () => { + const pending: Sep12Attestation = { ...baseAttestation, status: "PENDING", signature: "" }; + const signed = signAttestation(pending, orgKey); + expect(verifySep12Attestation({ attestation: signed, signingKey: orgKey.publicKey() })).toBe( + false, + ); + }); + + it("rejects a tampered payload", () => { + const signed = signAttestation(baseAttestation, orgKey); + const tampered: Sep12Attestation = { ...signed, name: "Evil Hacker" }; + expect(verifySep12Attestation({ attestation: tampered, signingKey: orgKey.publicKey() })).toBe( + false, + ); + }); + + it("evaluateCreatorVerification returns sep12-attestation when signed", () => { + const signed = signAttestation(baseAttestation, orgKey); + const result = evaluateCreatorVerification({ + account: CREATOR, + toml: tomlWithKey, + attestation: signed, + stellarTomlUrl: "https://creator.example/.well-known/stellar.toml", + }); + expect(result.status).toBe("verified"); + expect(result.method).toBe("sep12-attestation"); + expect(result.name).toBe("Ada Lovelace"); + }); + + it("evaluateCreatorVerification returns sep1-toml without an attestation", () => { + const result = evaluateCreatorVerification({ account: CREATOR, toml: TOML_BASE }); + expect(result.status).toBe("verified"); + expect(result.method).toBe("sep1-toml"); + }); + + it("evaluateCreatorVerification returns unverified when account missing", () => { + const result = evaluateCreatorVerification({ + account: "GABSENTABSENTABSENTABSENTABSENTABSENTABSENT", + toml: TOML_BASE, + }); + expect(result.status).toBe("unverified"); + }); +}); diff --git a/src/lib/identity/verify.ts b/src/lib/identity/verify.ts new file mode 100644 index 00000000..bb83b2b8 --- /dev/null +++ b/src/lib/identity/verify.ts @@ -0,0 +1,147 @@ +import { Keypair } from "@stellar/stellar-base"; +import type { + CreatorVerification, + Sep12Attestation, + StellarToml, +} from "./types"; +import { findCreatorInToml } from "./stellarToml"; + +/** Deterministic, signable representation of a SEP-12 attestation. */ +export function buildAttestationPayload(attestation: Sep12Attestation): string { + return [ + `schema=${attestation.schema}`, + `domain=${attestation.domain}`, + `account=${attestation.account}`, + `name=${attestation.name}`, + `type=${attestation.type}`, + `status=${attestation.status}`, + `issuedAt=${attestation.issuedAt}`, + ].join("\n"); +} + +export function isValidEd25519PublicKey(key?: string): boolean { + if (!key) return false; + try { + Keypair.fromPublicKey(key); + return true; + } catch { + return false; + } +} + +/** + * Verify a SEP-12-style attestation against the `SIGNING_KEY` published in the + * creator's `stellar.toml` (SEP-1). The signature is a base64 ed25519 + * signature over {@link buildAttestationPayload}. + */ +export function verifySep12Attestation(params: { + attestation: Sep12Attestation; + signingKey: string; +}): boolean { + const { attestation, signingKey } = params; + if (!isValidEd25519PublicKey(signingKey)) return false; + if (attestation.status !== "VERIFIED") return false; + let signature: Buffer; + try { + signature = Buffer.from(attestation.signature, "base64"); + } catch { + return false; + } + try { + const keypair = Keypair.fromPublicKey(signingKey); + return keypair.verify(Buffer.from(buildAttestationPayload(attestation), "utf8"), signature); + } catch { + return false; + } +} + +/** SEP-1 account ownership check: is the creator account declared in the TOML? */ +export function verifySep1Identity(params: { + account: string; + toml: StellarToml; +}): boolean { + return findCreatorInToml(params.toml, params.account).found; +} + +export interface EvaluateVerificationParams { + account: string; + toml: StellarToml; + attestation?: Sep12Attestation; + stellarTomlUrl?: string; + externalVerified?: boolean; +} + +/** + * Combine the SEP-1 account check and optional SEP-12 attestation into a single + * creator verification result. Falls back to an `external-link` signal when the + * marketplace reputation service has already confirmed a link. + */ +export function evaluateCreatorVerification( + params: EvaluateVerificationParams, +): CreatorVerification { + const { account, toml, attestation, stellarTomlUrl, externalVerified } = params; + const match = findCreatorInToml(toml, account); + + if (!match.found) { + if (externalVerified) { + return { + status: "verified", + method: "external-link", + stellarTomlUrl, + message: "Verified via an externally confirmed creator link.", + }; + } + return { + status: "unverified", + stellarTomlUrl, + message: "This Stellar account was not found in the domain's stellar.toml.", + }; + } + + const domain = attestation?.domain ?? extractDomain(stellarTomlUrl) ?? "stellar.toml"; + + if (attestation && toml.signingKey) { + const valid = verifySep12Attestation({ + attestation, + signingKey: toml.signingKey, + }); + if (!valid) { + return { + status: "error", + method: "sep12-attestation", + domain, + stellarTomlUrl, + message: "Attestation signature did not match the domain signing key.", + }; + } + return { + status: "verified", + method: "sep12-attestation", + domain, + name: attestation.name, + identityType: attestation.type, + issuedAt: attestation.issuedAt, + attestationUrl: attestation.attestationUrl, + stellarTomlUrl, + message: "Identity verified via SEP-1 discovery and a signed SEP-12 attestation.", + }; + } + + return { + status: "verified", + method: "sep1-toml", + domain, + name: match.name, + stellarTomlUrl, + message: "Creator account confirmed in the domain's stellar.toml (SEP-1).", + }; +} + +function extractDomain(stellarTomlUrl?: string): string | undefined { + if (!stellarTomlUrl) return undefined; + try { + return new URL(stellarTomlUrl).hostname; + } catch { + return undefined; + } +} diff --git a/src/pages/profile/page.tsx b/src/pages/profile/page.tsx index 3e6af771..cf6ca79e 100644 --- a/src/pages/profile/page.tsx +++ b/src/pages/profile/page.tsx @@ -37,6 +37,8 @@ import { NotificationPreferences } from "@/components/NotificationPreferences"; import { PostVersionUpdate } from "@/components/PostVersionUpdate"; import { CreatorReputationPanel } from "@/components/CreatorReputation"; import { ReputationSummary } from "@/components/ReputationSummary"; +import { CreatorVerificationCard } from "@/components/CreatorVerificationCard"; +import { VerifiedCreatorBadge } from "@/components/VerifiedCreatorBadge"; import { RecentlyViewed } from "@/components/RecentlyViewed"; import { SkeletonCard } from "@/components/Skeleton"; import { Badge } from "@/components/ui/badge"; @@ -45,6 +47,7 @@ import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useWallet } from "@/hooks/useWallet"; import { useWalletBalance } from "@/hooks/useWalletBalance"; +import { useCreatorVerification } from "@/hooks/useCreatorVerification"; import { invalidateAllPromptQueries } from "@/hooks/useContractSync"; import { browserStellarConfig } from "@/lib/stellar/browserConfig"; import { @@ -799,6 +802,8 @@ export default function ProfilePage() { const creatorShareUrl = profileAddress != null ? buildCreatorShareUrl(profileAddress) : null; + const publicVerification = useCreatorVerification(profileAddress ?? undefined); + const createdQuery = useQuery({ queryKey: ["created-prompts", profileAddress], queryFn: async () => @@ -1066,6 +1071,9 @@ export default function ProfilePage() {

{profileAddress}

+
+ +

{activeListingCount} active listing {activeListingCount === 1 ? "" : "s"} @@ -1267,6 +1275,7 @@ export default function ProfilePage() {

)}
+