diff --git a/components/agents/AskAnAgent.tsx b/components/agents/AskAnAgent.tsx new file mode 100644 index 0000000..200478a --- /dev/null +++ b/components/agents/AskAnAgent.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { Box, Text } from "@obolnetwork/obol-ui"; +import type { CSS } from "@stitches/react"; +import { VbButton } from "@components/ui/VbButton"; +import { AGENT_FILES, ASK_AGENT_ID } from "@lib/agents/prompts"; +import { copyText } from "@lib/share/clipboard"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +type AskAnAgentProps = { + /** The prompt, rendered into the static HTML so agents reading the DOM see it too. */ + prompt: string; + title?: string; + lede?: string; + headingAs?: "h2" | "h3"; + css?: CSS; +}; + +/** A visible, copyable prompt for the visitor's own AI assistant, plus links to the agent docs. */ +export function AskAnAgent({ + prompt, + title = "Ask your AI about your setup", + lede = "Paste this into ChatGPT, Claude, or any assistant that can read a URL. Fill in the brackets first.", + headingAs = "h2", + css, +}: AskAnAgentProps) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + if (!(await copyText(prompt))) return; + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + {title} + + + {lede} + + + {prompt} + + + + {copied ? "Prompt copied ✓" : "Copy prompt"} + + + For agents:{" "} + {AGENT_FILES.map((f, i) => ( + + {i > 0 && " · "} + + {f.path.slice(1)} + + + ))} + + + + ); +} diff --git a/components/assessment/AssessmentApp.tsx b/components/assessment/AssessmentApp.tsx index f637f2c..f5b53f3 100644 --- a/components/assessment/AssessmentApp.tsx +++ b/components/assessment/AssessmentApp.tsx @@ -27,6 +27,8 @@ import { CompareCard } from "./Compare"; import { Intro } from "./Intro"; import { Question } from "./Question"; import { LevelUp, ResultHero, ShareModal } from "./Results"; +import { AskAnAgent } from "@components/agents/AskAnAgent"; +import { assessPrompt, resultPrompt } from "@lib/agents/prompts"; import { SiteHeader } from "@components/layout/SiteHeader"; import { SiteFooter } from "@components/layout/SiteFooter"; import { @@ -137,6 +139,23 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { document.title = `${shareName} is ${STAGE_META[a.stage].name} | ${SITE_NAME}`; }, [guest, shareName, a.stage]); + // The intro is also the server-rendered state, so a share page's static HTML + // carries its own result prompt for agents reading the DOM. + const promptCode = a.atResults ? code : a.atIntro ? initialShareCode?.toUpperCase() : undefined; + const askAgent = (a.atIntro || a.atResults) && ( + + ); + const takeItYourself = () => { router.push(compareAssessPath({ code, name: shareName })); }; @@ -224,6 +243,7 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { )} ) : null} + {askAgent} {/* On phones the pizza leads, except on a shared link, where the name and stage should. */} diff --git a/components/landing/Landing.tsx b/components/landing/Landing.tsx index 05fd0ec..0c8d803 100644 --- a/components/landing/Landing.tsx +++ b/components/landing/Landing.tsx @@ -11,6 +11,8 @@ import { TopNavLink, risk, } from "@components/assessment/stitches"; +import { AskAnAgent } from "@components/agents/AskAnAgent"; +import { assessPrompt } from "@lib/agents/prompts"; import { SiteHeader } from "@components/layout/SiteHeader"; import { SiteFooter } from "@components/layout/SiteFooter"; import { @@ -686,6 +688,16 @@ function LClosing() { ); } +function LAgent() { + return ( + + + + + + ); +} + export function Landing() { return ( @@ -699,6 +711,7 @@ export function Landing() { + diff --git a/components/layout/SiteFooter.tsx b/components/layout/SiteFooter.tsx index ecce1b7..5c12015 100644 --- a/components/layout/SiteFooter.tsx +++ b/components/layout/SiteFooter.tsx @@ -7,6 +7,9 @@ import { METHODOLOGY_PATH, VALOS_URL, } from "@constants/index"; +import { AGENT_FILES, ASK_AGENT_ID } from "@lib/agents/prompts"; + +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const navLink: CSS = { fontSize: "$2", @@ -70,6 +73,31 @@ export function SiteFooter({ contentWidth = 1140 }: SiteFooterProps) { Assess + + + For AI agents + + {AGENT_FILES.map((f) => ( + + {f.path.slice(1)} + + ))} + + Ask your AI + + ); } diff --git a/components/methodology/Inline.tsx b/components/methodology/Inline.tsx new file mode 100644 index 0000000..97bf803 --- /dev/null +++ b/components/methodology/Inline.tsx @@ -0,0 +1,12 @@ +import { Fragment } from "react"; + +/** Render a copy string's `**bold**` spans as (see lib/methodology/content.ts). */ +export function Inline({ text }: { text: string }) { + return ( + <> + {text.split(/\*\*(.+?)\*\*/g).map((part, i) => + i % 2 ? {part} : {part}, + )} + + ); +} diff --git a/lib/agents/agents.test.ts b/lib/agents/agents.test.ts new file mode 100644 index 0000000..9b54cdd --- /dev/null +++ b/lib/agents/agents.test.ts @@ -0,0 +1,58 @@ +import { QUESTIONS } from "@lib/assessment/questions"; +import { NUANCE_CASES, UNSCORED } from "@lib/methodology/content"; +import { SLICES, STAGE_META, TIPS, allShareCodes } from "@lib/rubric"; +import { llmsFullTxt, llmsTxt, sitemapXml, skillMd } from "./files"; +import { AGENT_FILES, assessPrompt, resultPrompt } from "./prompts"; + +describe("llms-full.txt", () => { + const doc = llmsFullTxt(); + + it("contains every slice, question, band, and tip from the rubric", () => { + for (const s of SLICES) { + const q = QUESTIONS[s.id]; + expect(doc).toContain(s.label); + expect(doc).toContain(s.why); + expect(doc).toContain(q.q); + expect(doc).toContain(q.helper); + for (const o of q.options) { + expect(doc).toContain(o.label); + expect(doc).toContain(o.sub); + } + for (const tip of Object.values(TIPS[s.id])) expect(doc).toContain(tip); + } + }); + + it("lists slices in share-code order", () => { + const positions = SLICES.map((s) => doc.indexOf(`(\`${s.id}\`, share-code position`)); + expect(positions.every((p) => p >= 0)).toBe(true); + expect([...positions].sort((a, b) => a - b)).toEqual(positions); + }); + + it("contains every stage and the methodology nuances", () => { + for (const m of Object.values(STAGE_META)) expect(doc).toContain(m.tagline); + for (const n of NUANCE_CASES) expect(doc).toContain(n.body); + for (const u of UNSCORED) expect(doc).toContain(u.body); + expect(doc).toContain(`${allShareCodes().length} valid codes`); + }); +}); + +describe("agent file links", () => { + it("llms.txt, skill.md and the sitemap point at llms-full.txt", () => { + expect(llmsTxt()).toContain("/llms-full.txt"); + expect(skillMd()).toContain("/llms-full.txt"); + for (const f of AGENT_FILES) expect(sitemapXml()).toContain(`${f.path}`); + }); +}); + +describe("prompts", () => { + it("embeds the share code and its stage", () => { + const p = resultPrompt("GYYGGY"); + expect(p).toContain("/GYYGGY/"); + expect(p).toContain("which is Stage 1"); + expect(p).toContain("reach Stage 2"); + }); + + it("points the assess prompt at skill.md", () => { + expect(assessPrompt()).toContain("/skill.md"); + }); +}); diff --git a/lib/agents/files.ts b/lib/agents/files.ts new file mode 100644 index 0000000..5b4b97c --- /dev/null +++ b/lib/agents/files.ts @@ -0,0 +1,321 @@ +/** + * Agent/LLM-facing static docs, built from the rubric, the questions, and the + * shared methodology copy so they can never drift from the real scoring. + * scripts/generate-agent-files.ts writes them into public/ before `next build`. + */ +import { DOWNTIME_CALCULATOR_URL, GITHUB_URL, SITE_URL, VALOS_URL } from "@constants/index"; +import { QUESTIONS } from "@lib/assessment/questions"; +import { + ACTIVE_ACTIVE, + BAND_MATH, + CLIENT_DIVERSITY_NOTE, + NOT_SURE, + NUANCE_CASES, + RULE_OF_THUMB, + THREE_COLORS, + UNSCORED, +} from "@lib/methodology/content"; +import { + COLORS, + SLICES, + STAGE_META, + TIPS, + allShareCodes, + computeStage, + decodeShareCode, + sliceChoices, + sliceTarget, + slicesOfKind, +} from "@lib/rubric"; +import type { SliceColor, SliceMeta } from "@lib/rubric"; +import { SHARE_NAME_MAX } from "@lib/share/share-url"; + +const BASE = SITE_URL.replace(/\/$/, ""); +const DOWNTIME_URL = DOWNTIME_CALCULATOR_URL.replace(/\/$/, ""); +const EXAMPLE_CODE = "GYYGGY"; + +const SAFETY = slicesOfKind("safety").map((s) => s.label).join(", "); +const LIVENESS = slicesOfKind("liveness").map((s) => s.label).join(" and "); + +const LETTER: Record = { green: "G", yellow: "Y", red: "R", unknown: "U" }; +const COLOR_NAME: Record = { + green: "Green", + yellow: "Yellow", + red: "Red", + unknown: "Not sure", +}; + +const STAGE_RULES = [ + `**Stage 0 — ${STAGE_META[0].kind}**: a safety slice (${SAFETY}) is red or not sure. ${STAGE_META[0].tagline}.`, + `**Stage 1 — ${STAGE_META[1].kind}**: every safety slice is green or yellow, but not all six are green. ${STAGE_META[1].tagline}.`, + `**Stage 2 — ${STAGE_META[2].kind}**: all six slices green. ${STAGE_META[2].tagline}.`, + ``, + `Colors mean the same thing on every slice: red = one failure could get you slashed, yellow = one failure could take you offline (or a partial safety gap). ${LIVENESS} are liveness slices (green or yellow only) and block Stage 2 but not Stage 1. "Not sure" is treated as an open gap.`, +].join("\n"); + +/** e.g. "Key Custody green, Client Diversity yellow, … → Stage 1". */ +function describeExample(): string { + const answers = decodeShareCode(EXAMPLE_CODE) ?? {}; + const stage = computeStage(answers); + const slices = SLICES.map((s) => `${s.label} ${COLOR_NAME[answers[s.id] ?? COLORS.unknown].toLowerCase()}`); + return `${slices.join(", ")} → ${stage == null ? "no stage" : STAGE_META[stage].name}`; +} + +export function llmsTxt(): string { + const sliceLines = SLICES.map((s, i) => `${i + 1}. **${s.label}** — ${s.why}`).join("\n"); + + return `# Validator Beat + +> A free, client-side self-assessment that scores Ethereum validator setups **Stage 0, 1, or 2** across six single points of failure. Nothing is stored or sent to a server; results are shareable via six-letter codes. Built by Obol as a public good, in partnership with Lido. + +Staking earns roughly 2% APR; slashing can take far more, and correlated failures are penalized super-linearly. Validator Beat makes a validator's resilience legible: six banded questions, each mapping to a green/yellow/red (or "not sure") "pizza slice", rolling up to one Stage. + +## Stages + +${STAGE_RULES} + +## The six slices (in canonical order) + +${sliceLines} + +The infrastructure slices (provider, OS, CPU, geography) assume the validator runs active/active — several cooperating nodes backing the same stake, with key shares split across them — so diversity translates into uptime and key safety, not just redundancy. + +## Share codes + +A result is encoded as six letters, one per slice in the order above: \`G\` (green), \`Y\` (yellow), \`R\` (red), or \`U\` (not sure). OS and CPU have no yellow band; Provider and Geography have no red band. Example: \`${BASE}/${EXAMPLE_CODE}\` — a stable URL with an Open Graph preview card and a README badge at \`${BASE}/badge/${EXAMPLE_CODE}.svg\`. All ${allShareCodes().length} valid combinations exist as static pages. + +## Docs + +- [Full reference](${BASE}/llms-full.txt): every question, answer band, stage rule, and methodology nuance in one file +- [Methodology](${BASE}/methodology/): scoring rules, the active/active assumption, nuances and limits +- [Take the assessment](${BASE}/assess/): six questions, ~60 seconds, runs in the browser +- [Agent skill](${BASE}/skill.md): how an AI agent can run this assessment conversationally +- [Source code](${GITHUB_URL}): Apache-2.0; the scoring rubric lives in \`lib/rubric\` + +## Related + +- [Validator Downtime](${DOWNTIME_URL}/) ([llms.txt](${DOWNTIME_URL}/llms.txt)): what correlated downtime would cost under EIP-7716 +`; +} + +/** What a slice answer means for the operator's stage. */ +function bandEffect(slice: SliceMeta, color: SliceColor): string { + const target = sliceTarget(slice, color); + if (target == null) return "No gap on this slice."; + return `Holds the result below ${STAGE_META[target].name}.`; +} + +function bandLines(slice: SliceMeta): string { + const q = QUESTIONS[slice.id]; + return sliceChoices(slice) + .map((color) => { + const head = `- **${COLOR_NAME[color]} (\`${LETTER[color]}\`)**`; + const option = q.options.find((o) => o.color === color); + const answer = + color === COLORS.unknown + ? "The operator can't say. Treated as an open gap." + : `${option?.label}. ${option?.sub}`; + const tip = color === COLORS.green ? null : TIPS[slice.id][color]; + const next = tip ? ` ${color === COLORS.unknown ? "How to find out" : "How to fix"}: ${tip}` : ""; + return `${head} — ${answer} ${bandEffect(slice, color)}${next}`; + }) + .join("\n"); +} + +function sliceSection(slice: SliceMeta, i: number): string { + const q = QUESTIONS[slice.id]; + const kind = + slice.kind === "safety" + ? "Safety slice: red or not sure holds the result at Stage 0." + : "Liveness slice: it only stands between the result and Stage 2."; + const refs = (q.references ?? []).map((r) => `- [${r.label}](${r.url})`).join("\n"); + return [ + `### ${i + 1}. ${slice.label} (\`${slice.id}\`, share-code position ${i + 1})`, + "", + `${kind} Bands offered: ${slice.bands.map((b) => COLOR_NAME[b].toLowerCase()).join(", ")}, plus not sure.`, + "", + `**Why it matters:** ${slice.why}`, + "", + `**Question:** ${q.q}`, + "", + `**Guidance:** ${q.helper}`, + "", + "**Answers:**", + "", + bandLines(slice), + ...(q.risk ? ["", `**Real-world risk:** ${q.risk}`] : []), + ...(refs ? ["", "**References:**", "", refs] : []), + ].join("\n"); +} + +export function llmsFullTxt(): string { + const codes = allShareCodes(); + const positions = SLICES.map( + (s, i) => `${i + 1}. ${s.label}: ${sliceChoices(s).map((c) => LETTER[c]).join(", ")}`, + ).join("\n"); + + return `# Validator Beat — full reference + +> A free, client-side self-assessment that scores Ethereum validator setups **Stage 0, 1, or 2** across six single points of failure. Six banded questions, each mapping to a green/yellow/red (or "not sure") slice, roll up to one Stage. Nothing is stored or sent to a server. Built by Obol as a public good, in partnership with Lido (the stewards of ValOS). + +This file is the whole site in one document, generated from the scoring code at build time. Site: ${BASE}/ · Short summary: ${BASE}/llms.txt · Agent skill for running the assessment conversationally: ${BASE}/skill.md · Source: ${GITHUB_URL} (Apache-2.0; scoring lives in \`lib/rubric\`). + +## Stages + +${STAGE_RULES} + +### Exact scoring rules + +The stage is computed in \`computeStage\` (\`lib/rubric/index.ts\`), in this order: + +1. If any of the six slices is unanswered, there is no stage. +2. If any **safety** slice (${SAFETY}) is red or not sure, the result is **Stage 0**. +3. Otherwise, if all six slices are green, the result is **Stage 2**. +4. Otherwise the result is **Stage 1**. + +So yellow on a safety slice still allows Stage 1, and any yellow or not-sure on a **liveness** slice (${LIVENESS}) only blocks Stage 2. The answer the operator picks *is* the slice color; there is no separate calculation. + +Band exceptions: Key Custody and Client Diversity offer green, yellow and red. OS Diversity and CPU Architecture offer only green or red (no yellow), because the only question is whether one OS or architecture holds enough key shares to sign. Provider Diversity and Geographic Diversity offer only green or yellow (no red), because the worst they can do is take the validator offline. Every slice also offers "not sure". + +${NOT_SURE} + +${BAND_MATH} + +${CLIENT_DIVERSITY_NOTE} + +**Rule of thumb:** ${RULE_OF_THUMB} + +## The six slices + +Ordered worst-failure-first. This order is also the share-code order. + +${SLICES.map(sliceSection).join("\n\n")} + +## The active/active assumption + +${ACTIVE_ACTIVE} + +## Nuances and limits + +${THREE_COLORS} + +Known cases the questions don't spell out: + +${NUANCE_CASES.map((n) => `- **${n.title}.** ${n.body}`).join("\n")} + +What the assessment deliberately doesn't score: + +${UNSCORED.map((u) => `- **${u.title}.** ${u.body}`).join("\n")} + +This is a self-assessment: a result reflects the operator's own answers, not verified facts. + +## Why correlation matters + +Ethereum already penalizes correlated slashing super-linearly: on top of the initial penalty, each slashed validator loses a share of its balance proportional to three times the fraction of total stake slashed around the same time, capped at the whole balance (\`PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX = 3\` in the consensus specs: https://github.com/ethereum/consensus-specs/blob/master/specs/bellatrix/beacon-chain.md). Correlated downtime isn't penalized that way today. EIP-7716 ("Anti-correlation attestation penalties", https://eips.ethereum.org/EIPS/eip-7716) is a Draft, proposed for inclusion in the Hegotá upgrade (https://forkcast.org/eips/7716), that would scale missed-attestation penalties with how many validators miss together: validators that fail alone would pay what they pay today, and correlated failures would pay more. The Provider and Geography slices decide whether a validator fails alone or with a crowd. The EIP-7716 downtime calculator (${DOWNTIME_URL}/) shows the numbers. + +## Share codes + +A result is encoded as six letters, one per slice in canonical order: \`G\` (green), \`Y\` (yellow), \`R\` (red), \`U\` (not sure). Each position only accepts the bands its slice offers: + +${positions} + +That gives ${codes.length} valid codes, and every one exists as a static page at \`${BASE}//\` with an Open Graph preview card at \`${BASE}/og/.png\` and a README badge at \`${BASE}/badge/.svg\`. Codes that use a band a slice doesn't offer (e.g. \`Y\` for OS) are invalid. + +Example: \`${EXAMPLE_CODE}\` = ${describeExample()}. Link: ${BASE}/${EXAMPLE_CODE}/ · Badge: \`[![Validator Beat](${BASE}/badge/${EXAMPLE_CODE}.svg)](${BASE}/${EXAMPLE_CODE}/)\` + +Optional query parameters: \`?n=\` names the result (max ${SHARE_NAME_MAX} characters, URL-encoded); \`&vs=&vn=\` shows it head-to-head against another result. + +## Related + +- [Validator Downtime](${DOWNTIME_URL}/) ([llms.txt](${DOWNTIME_URL}/llms.txt)): what correlated downtime would cost under EIP-7716. Client Diversity is the safety side of the same story: a supermajority-client bug is either mass slashing or, with refuse-to-attest, correlated downtime. +- [ValOS — the Validator Operations Standard](${VALOS_URL}): the risk-and-mitigation catalog each question links into. +- [clientdiversity.org](https://clientdiversity.org/): live network share for each execution and consensus client. +- [Obol docs](https://docs.obol.org/): distributed validators, Charon, and chain-split safety settings. +`; +} + +export function skillMd(): string { + const questions = SLICES.map((s, i) => { + const q = QUESTIONS[s.id]; + const options = [ + ...q.options.map((o) => ` - **${o.color.toUpperCase()}** — ${o.label}. ${o.sub}`), + " - **NOT SURE** — the user can't say. Treated as an open gap.", + ].join("\n"); + return `${i + 1}. **${s.label}** (\`${s.id}\`)\n Ask: "${q.q}"\n${options}`; + }).join("\n\n"); + + return `--- +name: validator-beat-assessment +description: Assess the resilience of an Ethereum validator setup by asking six questions, then give the operator their Stage (0, 1, or 2) and a shareable Validator Beat link. +--- + +# Validator Beat assessment + +You can score any Ethereum validator setup the same way ${BASE} does. Ask the six questions below, map each answer to a color, compute the Stage, and construct a share URL. The scoring is fully deterministic — no API needed. For each slice's rationale, remediation tips, and the methodology's nuances, read ${BASE}/llms-full.txt. + +## The six questions (canonical order — this order defines the share code) + +${questions} + +## Scoring + +Each answer is exactly one of the listed options, or "not sure". If the user's setup falls between two answers, pick the worse one when the gray area hides a single point of failure. If they can't answer, use "not sure" rather than guessing. + +${STAGE_RULES} + +## Share URL + +Map each answer to a letter — G (green), Y (yellow), R (red), U (not sure) — in question order to form a six-letter code, then link to \`${BASE}/\` — e.g. answers green, yellow, yellow, green, green, yellow → \`${BASE}/${EXAMPLE_CODE}\`. Optionally append \`?n=\` (max ${SHARE_NAME_MAX} chars, URL-encoded) to name the result, and \`&vs=&vn=\` to show it head-to-head against another result. + +Every code resolves to a static page with an Open Graph preview card, so the link unfurls with the result pizza in chat apps and social feeds. + +## Caveats to relay + +- This is a self-assessment: it reflects the operator's answers, not verified facts. +- The infrastructure slices (provider, OS, CPU, geography) assume an active/active setup — several cooperating nodes backing the same stake. Active/passive failover makes Provider and Geography yellow. +- Full nuances: ${BASE}/methodology/#nuances, or all of it in one file: ${BASE}/llms-full.txt +`; +} + +/** Core pages plus the agent docs. Share pages are deliberately left out. */ +export function sitemapXml(): string { + const entries: [string, string, string][] = [ + ["/", "weekly", "1.0"], + ["/assess/", "weekly", "0.9"], + ["/methodology/", "monthly", "0.8"], + ["/llms.txt", "monthly", "0.5"], + ["/llms-full.txt", "monthly", "0.5"], + ["/skill.md", "monthly", "0.5"], + ]; + const urls = entries + .map( + ([p, freq, prio]) => + ` \n ${BASE}${p}\n ${freq}\n ${prio}\n `, + ) + .join("\n"); + return ` + + +${urls} + +`; +} + +export function robotsTxt(): string { + return `# Validator Beat — everything is public. Share pages (/GYRYGG etc.) are +# intentionally crawlable so link unfurls work everywhere. +# Generated by scripts/generate-agent-files.ts. +User-agent: * +Allow: / + +Sitemap: ${BASE}/sitemap.xml + +# LLM/agent-facing docs +# ${BASE}/llms.txt +# ${BASE}/llms-full.txt +# ${BASE}/skill.md +`; +} diff --git a/lib/agents/prompts.ts b/lib/agents/prompts.ts new file mode 100644 index 0000000..c93b088 --- /dev/null +++ b/lib/agents/prompts.ts @@ -0,0 +1,43 @@ +import { SITE_URL } from "@constants/index"; +import { STAGE_META, computeStage, decodeShareCode } from "@lib/rubric"; + +const BASE = SITE_URL.replace(/\/$/, ""); + +/** Agent-facing static docs, generated by scripts/generate-agent-files.ts. */ +export const AGENT_FILES = [ + { path: "/llms.txt", title: "llms.txt" }, + { path: "/llms-full.txt", title: "llms-full.txt" }, + { path: "/skill.md", title: "Agent Skill" }, +] as const; + +/** Anchor of the visible copy-prompt block. */ +export const ASK_AGENT_ID = "ask-an-agent"; + +/** Prompt for a visitor's own AI assistant to run the assessment conversationally. */ +export function assessPrompt(): string { + return [ + `Read ${BASE}/skill.md and use it to run the Validator Beat assessment with me (full reference: ${BASE}/llms-full.txt).`, + "", + "Ask me the six questions one at a time. If my setup falls between two answers, help me pick the worse one; if I can't tell, use \"not sure\". Then tell me my Stage, which slices hold me back and the cheapest fix for each, and give me my Validator Beat share link.", + "", + "My setup, to start: [validators, execution and consensus clients, hosting providers and regions, signer / key setup].", + ].join("\n"); +} + +/** Prompt for a visitor's own AI assistant to explain a specific result. */ +export function resultPrompt(code: string): string { + const answers = decodeShareCode(code); + const stage = answers ? computeStage(answers) : null; + const stageText = stage == null ? "" : `, which is ${STAGE_META[stage].name}`; + const goal = + stage === 2 + ? "and what I need to keep doing to stay at Stage 2" + : `and the cheapest changes to reach ${STAGE_META[stage === 0 ? 1 : 2].name}`; + return [ + `Read ${BASE}/llms-full.txt. My Validator Beat result is ${code} (${BASE}/${code}/)${stageText}.`, + "", + `Explain what each slice means for my setup, which single failures I'm still exposed to, ${goal}.`, + "", + "My setup: [validators, execution and consensus clients, hosting providers and regions, signer / key setup].", + ].join("\n"); +} diff --git a/lib/methodology/content.ts b/lib/methodology/content.ts new file mode 100644 index 0000000..1a18d0e --- /dev/null +++ b/lib/methodology/content.ts @@ -0,0 +1,66 @@ +/** + * Methodology copy shared by the methodology page and the generated agent + * docs (llms-full.txt), so the two can't drift. Strings use `**bold**` + * Markdown, which `components/methodology/Inline.tsx` renders on the page. + */ + +export const ACTIVE_ACTIVE = + "For infrastructure, OS, CPU, and geography, diversity only translates into resilience when your validator runs **active/active**: several cooperating nodes back the same stake, with signing continuing as long as enough of them stay up. The stake isn't partitioned across machines — it's one aggregate validator whose cooperating machines you've diversified. Several setups achieve this — multi-operator **distributed validators (DVT)** coordinated by Charon, or validator clients like **Vouch** paired with multiplexers like **Vero** and remote signers like **Dirk** or **Web3Signer**. The common requirement: no single party holds enough key material to sign alone — at least two independent parties involved, backups kept separate, so compromising one doesn't leak the full private key."; + +export const NOT_SURE = + "Every question also offers **Not sure**. It's treated as an open gap: a gap you can't verify is closed has to be assumed open, so it holds a safety slice at Stage 0 and a liveness slice short of Stage 2. Your results then tell you how to find out, rather than how to fix it."; + +export const BAND_MATH = + "Bands follow the consensus math. Distributed validators typically need ⅔ of key shares to sign, so any single party, OS, or architecture holding ⅔ or more is red. Holding ⅓ or less means losing it still leaves a signing threshold online, which is green. For Provider and Geography, anything over ⅓ on one provider or region can already drop you below that threshold, so there's a single cut-off."; + +export const CLIENT_DIVERSITY_NOTE = + "**Client diversity** counts execution and consensus clients alike and uses a simplified banded model; live network client share thresholds are deferred to a future operator registry."; + +export const RULE_OF_THUMB = + "If your setup falls between two answers, pick the worse one. If you can't tell, pick **Not sure** — your results will say how to find out."; + +export const THREE_COLORS = + "Real setups don't fall into neat green, yellow, and red buckets. We use three colors anyway because they keep a result easy to read at a glance — the rule of thumb covers the space between them."; + +/** Known cases the questions don't spell out. */ +export const NUANCE_CASES: { title: string; body: string }[] = [ + { + title: "Shared owner", + body: "Two key custodians inside one company fail together. Count them as one party when scoring Key Custody.", + }, + { + title: "Derived distros", + body: "Ubuntu and Debian share upstream packaging. If your distros share a supply chain, count them as one OS.", + }, + { + title: "One physical host", + body: "Two OSes in VMs on one machine are both reachable through the host. Count the host's OS, not the guests'.", + }, + { + title: "Resold infrastructure", + body: "Two providers reselling the same cloud or data centre fail together. Count them as one provider.", + }, + { + title: "Active/passive failover", + body: "A warm standby still goes offline during failover. The Provider and Geography slices assume active/active; score them yellow.", + }, +]; + +/** Things the assessment deliberately doesn't score, and why. */ +export const UNSCORED: { id: string; title: string; body: string }[] = [ + { + id: "remote-signers", + title: "The remote signer stack", + body: "Every current signing stack keeps a single point of failure somewhere, whether you run Web3Signer, Dirk, Vero, Vouch, or Charon. Validator Beat doesn't score it: penalizing something no setup can avoid would push everyone toward one architecture.", + }, + { + id: "hosting-type", + title: "Hosting provider type", + body: "Validator Beat doesn't rank residential, bare metal, or cloud hosting. The Provider slice already scores provider concentration, which catches the real trap: five regions all on AWS still fall to one provider incident.", + }, + { + id: "cpu-generation", + title: "CPU generation", + body: "Validator Beat counts instruction sets and ignores chip generations. Hardware bugs sometimes hit one generation and sometimes a vendor's entire line, so mixing generations guarantees nothing.", + }, +]; diff --git a/pages/_document.tsx b/pages/_document.tsx index 69c98f5..64b534d 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -1,6 +1,7 @@ /* eslint-disable @next/next/google-font-display */ import "@components/assessment/stitches"; import { getCssText, styled } from "@obolnetwork/obol-ui"; +import { AGENT_FILES } from "@lib/agents/prompts"; import { Head, Html, Main, NextScript } from "next/document"; const Body = styled("body", { @@ -22,6 +23,17 @@ export default function Document() { + + {/* Machine-readable versions for LLMs and agents (llmstxt.org). */} + {AGENT_FILES.map((f) => ( + + ))} = { @@ -34,9 +49,29 @@ function MiniPizza({ colors }: { colors: SliceColor[] }) { ); } +const BASE = SITE_URL.replace(/\/$/, ""); + +const JSON_LD = { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: `${SITE_NAME} methodology`, + description: + "How Validator Beat scores six single points of failure in an Ethereum validator setup into Stage 0, 1, or 2.", + url: `${BASE}/methodology/`, + isPartOf: { "@type": "WebSite", name: SITE_NAME, url: `${BASE}/` }, + publisher: { "@type": "Organization", name: "Obol", url: "https://obol.org" }, + sameAs: [GITHUB_URL], +}; + export default function MethodologyPage() { return (
+ +