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
5 changes: 5 additions & 0 deletions .changeset/sep-identity-500.md
Original file line number Diff line number Diff line change
@@ -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.
108 changes: 108 additions & 0 deletions src/components/CreatorVerificationCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
aria-labelledby="creator-verification-title"
className="rounded-2xl border border-white/10 bg-[#0d1117] p-5 sm:p-6"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-emerald-300/20 bg-emerald-300/10 text-emerald-100">
<ShieldCheck className="h-5 w-5" />
</div>
<div>
<h2
id="creator-verification-title"
className="text-lg font-semibold text-white"
>
Creator identity verification
</h2>
<p className="text-xs text-slate-400">
SEP-1 domain proof + SEP-12 signed attestation
</p>
</div>
<div className="ml-auto">
<VerifiedCreatorBadge verification={verification} variant="compact" />
</div>
</div>

<p className="mt-4 text-sm leading-6 text-slate-400">
Verify ownership of a domain to earn a{" "}
<span className="text-emerald-200">Verified creator</span> badge. Host a{" "}
<code className="rounded bg-white/10 px-1 py-0.5 text-xs">stellar.toml</code>{" "}
at{" "}
<code className="rounded bg-white/10 px-1 py-0.5 text-xs">
https://your-domain/.well-known/stellar.toml
</code>{" "}
that lists your account{" "}
<code className="rounded bg-white/10 px-1 py-0.5 text-xs">
{shortenAddress(address)}
</code>
.
</p>

<form onSubmit={handleVerify} className="mt-4 flex flex-col gap-3 sm:flex-row">
<Input
value={domain}
onChange={(event) => 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"
/>
<Button
type="submit"
disabled={isLoading || !domain.trim()}
className="h-10 shrink-0 bg-emerald-300 text-slate-950 hover:bg-emerald-200 disabled:opacity-50"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying…
</>
) : (
<>
<BadgeCheck className="h-4 w-4" />
Verify identity
</>
)}
</Button>
</form>

{verification?.status === "verified" && verification.message ? (
<p className="mt-3 text-sm text-emerald-200">{verification.message}</p>
) : null}
{error ? (
<p
role="alert"
className="mt-3 text-sm text-rose-200"
>
{error}
</p>
) : null}
</section>
);
}
8 changes: 8 additions & 0 deletions src/components/ReputationSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<section
Expand Down Expand Up @@ -193,6 +200,7 @@ export function ReputationSummary({ address }: { address: string }) {
</div>
{badges.length > 0 && (
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:justify-end">
<VerifiedCreatorBadge verification={verification} />
{badges.map((badge) => {
const Icon = BADGE_ICONS[badge.key];
return (
Expand Down
51 changes: 51 additions & 0 deletions src/components/VerifiedCreatorBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<VerifiedCreatorBadge verification={verification} />);
expect(screen.getByText("Verified creator")).toBeInTheDocument();
});

it("renders nothing for an unverified creator", () => {
const verification: CreatorVerification = { status: "unverified" };
const { container } = render(<VerifiedCreatorBadge verification={verification} />);
expect(container).toBeEmptyDOMElement();
});

it("renders nothing for an error state", () => {
const verification: CreatorVerification = {
status: "error",
message: "bad sig",
};
const { container } = render(<VerifiedCreatorBadge verification={verification} />);
expect(container).toBeEmptyDOMElement();
});

it("shows a pending chip for pending verification", () => {
const verification: CreatorVerification = { status: "pending" };
render(<VerifiedCreatorBadge verification={verification} />);
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(
<VerifiedCreatorBadge verification={verification} variant="compact" />,
);
expect(screen.queryByText("Verified creator")).not.toBeInTheDocument();
expect(container.querySelector("span[role='status']")).toBeInTheDocument();
});
});
101 changes: 101 additions & 0 deletions src/components/VerifiedCreatorBadge.tsx
Original file line number Diff line number Diff line change
@@ -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<VerificationMethod, string> = {
"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 (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1.5 text-xs font-medium text-amber-200",
className,
)}
title="Identity verification in progress"
>
<BadgeCheck aria-hidden="true" className="h-3.5 w-3.5" />
Verification pending
</span>
);
}

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 = (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-emerald-300/30 bg-emerald-300/10 px-3 py-1.5 text-xs font-semibold text-emerald-100",
className,
)}
>
<BadgeCheck aria-hidden="true" className="h-3.5 w-3.5" />
{variant === "full" ? "Verified creator" : null}
</span>
);

return (
<Tooltip
content={
<span className="block">
<span className="font-medium">{methodLabel}</span>
{detailLines.map((line) => (
<span key={line} className="block text-slate-300">
{line}
</span>
))}
{verification.stellarTomlUrl ? (
<a
href={verification.stellarTomlUrl}
target="_blank"
rel="noreferrer noopener"
className="mt-1 inline-block text-emerald-300 underline"
>
View stellar.toml
</a>
) : null}
</span>
}
>
<span
className="inline-flex cursor-default items-center"
role="status"
aria-label={`Verified creator via ${methodLabel}`}
>
{badgeContent}
</span>
</Tooltip>
);
}
74 changes: 74 additions & 0 deletions src/hooks/useCreatorVerification.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
Loading