diff --git a/.gitignore b/.gitignore
index 369359b..9f83732 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@
# production
/build
/public/og/*.png
+/public/badge/*.svg
# misc
.DS_Store
diff --git a/CLAUDE.md b/CLAUDE.md
index 9610f2c..521c929 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this project is
-Validator Beat is a **client-side, public-good self-assessment** for Ethereum validator operators. Six banded questions → six "pizza slices" (each green/yellow/red) → one Stage (0, 1, or 2). The whole thing runs in the browser; nothing is submitted, stored, or fetched at runtime. The static export ships to GitHub Pages.
+Validator Beat is a **client-side, public-good self-assessment** for Ethereum validator operators. Six banded questions → six "pizza slices" (each green/yellow/red, or grey for "not sure") → one Stage (0, 1, or 2). The whole thing runs in the browser; nothing is submitted, stored, or fetched at runtime. The static export ships to GitHub Pages. Built by Obol, in partnership with Lido (the stewards of ValOS) — never call Lido a co-author.
-Authoritative product plan: [`docs/PLAN.md`](./docs/PLAN.md). Read it before changing the rubric, stages, or slice set.
+The rubric's source of truth is `lib/rubric/` plus the public methodology page (`pages/methodology.tsx`). Pre-launch, breaking rubric/share-code changes are fine — update every surface rather than documenting the old behaviour.
## Domain context: why these six slices
@@ -16,14 +16,16 @@ The six slices are deliberately ordered worst-failure-first:
1. **Key Custody** — concentration of signing power (a Safety risk; one compromise can sign with the operator's stake)
2. **Client Diversity** — supermajority-client bug exposure (a Safety risk; refuse-to-attest turns it into mere downtime)
-3. **Infrastructure** — hosting-provider concentration (a Liveness risk)
-4. **OS Diversity** — distro monoculture (supply-chain risk; can be Safety *or* Liveness depending on the compromise)
-5. **CPU Architecture** — ISA monoculture (hardware-level supply-chain / side-channel risk)
-6. **Geographic** — jurisdictional concentration (Liveness + censorship risk)
+3. **Provider** — hosting-provider concentration (a Liveness risk)
+4. **OS Diversity** — distro monoculture (a Safety risk: one supply-chain compromise reaching a signing threshold)
+5. **CPU Architecture** — ISA monoculture (a Safety risk: one hardware/side-channel flaw reaching a signing threshold)
+6. **Geographic** — jurisdictional concentration (a Liveness risk, plus censorship)
+
+Each slice has a `kind`: **safety** (Keys, Clients, OS, CPU) or **liveness** (Provider, Geo). Stage rules (`computeStage`): Stage 0 = any safety slice red or not sure; Stage 1 = every safety slice green/yellow; Stage 2 = all six green. Colors carry one meaning: red = one failure could slash you, yellow = one failure could take you offline (or a partial safety gap). So liveness slices are `bands: [green, yellow]` and safety yes/no slices are `[green, red]`. "Not sure" (`unknown`, share letter `U`) is an open gap: it holds a safety slice at Stage 0 and a liveness slice short of Stage 2.
The bottom four slices (Infra / OS / CPU / Geo) **assume the operator runs per-validator DVT or a multiplexer** — i.e. duties are split across independent nodes so diversity translates into uptime rather than just redundancy. This premise is stated in `pages/methodology.tsx` and in each question's `helper` text.
-Banding thresholds (⅓ / ⅔) are chosen to map to the consensus-layer math: a ⅔ supermajority can finalize a wrong chain, so any single party holding ⅔ is a red; ⅓ is the safety threshold below which no single party can block or force anything.
+Banding thresholds (⅓ / ⅔) map to the threshold-signing math: DV clusters typically need ⅔ of key shares to sign, so any single point holding **⅔ or more** is red, and **⅓ or less** is green (losing it still leaves a threshold online). OS and CPU are binary (`bands: [green, red]`): the question is whether one OS/ISA holds a signing threshold. Provider and Geo are binary (`bands: [green, yellow]`): over ⅓ on one provider/region can already drop you below the threshold, so there's one cut-off, and the worst case is downtime, not slashing.
## Architecture
@@ -42,11 +44,11 @@ Banding thresholds (⅓ / ⅔) are chosen to map to the consensus-layer math: a
└─────────────────────────────────────────────────────────────┘
```
-- **`lib/rubric/`** is the single source of truth for scoring. It exports `SLICES` (the canonical ordered list — also defines pizza slice order and share-code character order), `computeStage`, `blockers`, `getTip`, `shareCode`, `decodeShareCode`. Pure, fully unit-tested.
-- **`lib/assessment/questions.ts`** holds question copy + banded options. Each option's `color` is the rubric input — there is no separate scoring step in v0.1; the user's chosen band *is* the slice color.
+- **`lib/rubric/`** is the single source of truth for scoring. It exports `SLICES` (the canonical ordered list with each slice's `kind` and `bands` — also defines pizza slice order and share-code character order), `computeStage`, `blockers`, `sliceTarget`, `getTip`, `shareCode`, `decodeShareCode`, `allShareCodes`. Pure, fully unit-tested; a test asserts `questions.ts` options match each slice's `bands`.
+- **`lib/assessment/questions.ts`** holds question copy + banded options. Each option's `color` is the rubric input — there is no separate scoring step; the user's chosen band *is* the slice color. The "Not sure" option is appended to every question by `Question.tsx`.
- **`hooks/useAssessment.ts`** is the state machine (intro → 6 questions → results) with a 420 ms auto-advance after each answer.
-- **`pages/[code].tsx`** statically generates **all 729 share pages** (3⁶) at build time via `getStaticPaths` + `lib/theme/share-codes.ts`. Each share URL like `/GYRYGG` rehydrates the assessment to that result.
-- **`scripts/generate-og-images.ts`** runs *before* `next build` (see `package.json` `build` script) and pre-renders **729 OG preview PNGs** into `public/og/` via `sharp`. Both the static pages and the OG images must stay in sync with `decodeShareCode`.
+- **`pages/[code].tsx`** statically generates **one share page per valid code** (`allShareCodes()`, currently 1,296) at build time. Each share URL like `/GYYGGY` rehydrates the assessment to that result in "guest" mode (big owner name from `?n=`, "Take it yourself & compare" CTA). `?vs=CODE&vn=Name` adds a head-to-head (`components/assessment/Compare.tsx`); `lib/share/share-url.ts` owns all query parsing/building.
+- **`scripts/generate-og-images.ts`** runs *before* `next build` (see `package.json` `build` script) and pre-renders an OG preview PNG into `public/og/` and a README badge SVG into `public/badge/` for every code. Both must stay in sync with `allShareCodes`.
### Hard constraints from the static-export setup
@@ -65,19 +67,20 @@ Banding thresholds (⅓ / ⅔) are chosen to map to the consensus-layer math: a
|---|---|
| `yarn dev` | Next dev server on :3000 |
| `yarn test` | Jest (jsdom) — primarily `lib/rubric/*.test.ts` |
-| `yarn test -t "all green"` | Run a single test by name |
+| `yarn test -t "not sure"` | Run a single test by name |
| `yarn lint` | `next lint` (lints `pages`, `components`, `lib`, `constants`, `scripts`) |
| `yarn tsc --noEmit` | Type-check only (also runs via husky pre-commit on staged files) |
| `yarn build` | `build:og` → `next build` → static `out/` |
-| `yarn build:og` | Regenerate the 729 OG PNGs only (slow; run when share-card visuals change) |
+| `yarn build:og` | Regenerate the OG PNGs + badges only (~1 min; run when share-card visuals change) |
+| `yarn build:banner` | Regenerate the committed README banner (`docs/images/banner.png`) |
| `npx serve out` | Serve the static export locally to verify share pages, OG tags, and trailing slashes |
Pre-commit (husky) runs `yarn test` + `yarn lint`. Lint-staged additionally runs `tsc --noEmit`, ESLint --fix, and Prettier on staged files.
## Conventions worth knowing
-- **The rubric is the contract.** If you add, rename, or reorder a slice, you must update: `lib/rubric/types.ts` (`SliceId` union), `lib/rubric/index.ts` (`SLICES`, `TIPS`), `lib/assessment/questions.ts`, the `Pizza` component's slice order, and the share-code length/test (`/^[GYR_]{6}$/` regex in `decodeShareCode`). The share-code character order **is** `SLICES` order — breaking it invalidates every existing share link.
-- **Share codes are user-visible URLs.** Six chars, `G`/`Y`/`R`, one per slice. Treat as a stable wire format.
+- **The rubric is the contract.** If you add, rename, or reorder a slice, you must update: `lib/rubric/types.ts` (`SliceId` union), `lib/rubric/index.ts` (`SLICES`, `TIPS`), `lib/assessment/questions.ts`, the `Pizza` component's slice order, and the methodology/landing examples. The share-code character order **is** `SLICES` order — breaking it invalidates every existing share link.
+- **Share codes are user-visible URLs.** Six chars, `G`/`Y`/`R`/`U`, one per slice; `decodeShareCode` rejects bands a slice doesn't offer. Treat as a stable wire format once launched.
- Stage rules live in one place — `computeStage` in `lib/rubric/index.ts`. Don't reimplement them in components.
- Copy lives close to the rubric: question text in `lib/assessment/questions.ts`, slice `why` and remediation tips in `lib/rubric/index.ts`. Methodology page reads from `SLICES` so descriptions stay in sync.
- Styling is a hybrid: `@obolnetwork/obol-ui` Stitches components (see `components/assessment/stitches.ts`) for the assessment shell, plus plain CSS files in `styles/` (CSS custom properties in `theme-tokens.css` are the palette source of truth — if you change pizza slice colors there, also update `lib/theme/tokens.ts`).
@@ -100,4 +103,10 @@ Pre-commit (husky) runs `yarn test` + `yarn lint`. Lint-staged additionally runs
## Deferred work (don't build yet unless asked)
-`data/operators/`, `content/methodology/`, `components/operators/`, and `lib/schemas/` are placeholder dirs for the **v1.2 operator registry** (YAML profiles + Zod validation + a `/operators` summary table). `scripts/validate-operators.ts` and `scripts/import-survey-csv.ts` are scaffolding for that phase. v0.1 deliberately does not ship any of this — the `/operators` route currently redirects to `/`.
+`data/operators/`, `content/methodology/`, `components/operators/`, and `lib/schemas/` are placeholder dirs for the **operator registry** (YAML profiles + Zod validation + a `/operators` summary table). `scripts/validate-operators.ts` and `scripts/import-survey-csv.ts` are scaffolding for that phase. The `/operators` route currently redirects to `/`.
+
+Preferred design when we pick it up — **Git as the backend**, no stateful service:
+
+- Operators open a PR adding `data/operators/.yaml` with their share code, name, links, and optional evidence. The PR thread is the public audit trail; CODEOWNERS review is the moderation step.
+- CI validates the YAML (Zod) and that the code passes `decodeShareCode`; the static build renders `/operators` and per-operator pages from the files.
+- Later: per-slice minimums for stages, live client-share checks against clientdiversity.org, and automating `ethSecured`.
diff --git a/README.md b/README.md
index 6ec259c..b83dc25 100644
--- a/README.md
+++ b/README.md
@@ -1,101 +1,85 @@
-# Validator Beat
+
+
+
Validator Beat
+
Six questions. Six slices. One Stage. A neutral, public-good self-assessment of how resilient an Ethereum validator setup really is.
+
+
+
+
+
+
+
+
+
-Neutral, public-good **self-assessment** for Ethereum validator operators — six questions, six pizza slices, one Stage. Runs entirely in your browser; nothing is submitted or stored.
+---
-**Planning:** [docs/PLAN.md](./docs/PLAN.md)
+Staking earns a few percent a year. One bad day can take far more. Validators rarely fail because of something exotic — they fail at a **single point of failure** nobody wrote down: one person with the mnemonic, one supermajority client, one cloud account, one distro, one CPU architecture, one country's power grid.
-## Development
+Validator Beat makes those single points of failure legible. Answer six questions, get a pizza with six colored slices, and a Stage anyone can read in five seconds.
-Requires **Node.js 24+** (see `.nvmrc`).
+## The stages
-```bash
-yarn install
-yarn dev
-```
-
-Open http://localhost:3000
-
-| Command | Description |
-|---------|-------------|
-| `yarn dev` | Local dev server |
-| `yarn build` | Static export to `out/` |
-| `yarn test` | Rubric unit tests |
-| `yarn lint` | ESLint |
+| | Stage | Meaning | Rule |
+|---|---|---|---|
+| 🔴 | **Stage 0 · Getting started** | One failure could get you slashed. | A safety slice is red or *not sure* |
+| 🟡 | **Stage 1 · Safety** | No single failure can get you slashed. | Every safety slice is green or yellow |
+| 🟢 | **Stage 2 · Liveness** | No single failure can slash you *or* take you offline. | All six slices green |
-Optional: set `NEXT_PUBLIC_SITE_URL` in `.env.local` for share links (defaults to `https://validatorbeat.com`).
+## The six slices
-## Deploy (GitHub Pages)
+| Slice | Guards against | Kind |
+|---|---|---|
+| **Key Custody** | One party, machine, or custodian holding enough key material to sign | Safety |
+| **Client Diversity** | Following a buggy supermajority execution or consensus client onto the wrong chain | Safety |
+| **Provider Diversity** | One hosting provider's outage taking you offline | Liveness |
+| **OS Diversity** | One distro's supply chain reaching enough key shares to sign | Safety |
+| **CPU Architecture** | One ISA-level flaw reaching enough key shares to sign | Safety |
+| **Geographic Diversity** | One country or region's outage taking you offline | Liveness |
-Every push to `main` runs CI and, on success, deploys the static `out/` folder to GitHub Pages.
+Colors mean one thing everywhere: 🔴 **red** — one failure could get you slashed; 🟡 **yellow** — one failure could take you offline (or a safety gap is only partly closed); 🟢 **green** — no single failure can do either. Bands follow the threshold-signing math: distributed validators typically need ⅔ of key shares to sign, so a single point holding ⅔ or more is red and ⅓ or less is green. Every question also offers ⚪ **Not sure**, treated as an open gap — one you can't verify is closed has to be assumed open. Liveness matters more than it used to: under [EIP-7716](https://eips.ethereum.org/EIPS/eip-7716), correlated downtime could cost far more than it does today (see the [downtime calculator](https://validatordowntime.obol.org)).
-**One-time repo setup**
+The full rationale, nuances, and known limits are in the [methodology](https://validatorbeat.com/methodology/).
-1. **Settings → Pages → Build and deployment** — set **Source** to **GitHub Actions**.
-2. Production builds use the GitHub Pages project path in [`.github/workflows/ci.yml`](.github/workflows/ci.yml):
- - `NEXT_PUBLIC_BASE_PATH=/validator-beat` — required so `/_next/` JS/CSS load at `obolnetwork.github.io/validator-beat/`
- - `NEXT_PUBLIC_SITE_URL=https://obolnetwork.github.io/validator-beat` — share links and OG URLs
-3. **Custom domain** (`validatorbeat.com`): point DNS at GitHub Pages, then rebuild with `NEXT_PUBLIC_BASE_PATH=` (empty) and `NEXT_PUBLIC_SITE_URL=https://validatorbeat.com`.
+## Share it
-**Theming:** edit [`styles/theme-tokens.css`](styles/theme-tokens.css) to change colors (warm cream, ecosystem-neutral by default; an Obol-branded dark palette under `[data-theme="dark"]`). If you change pizza slice colors, also update [`lib/theme/tokens.ts`](lib/theme/tokens.ts).
+Every result is a six-letter code, one letter per slice — `G`reen, `Y`ellow, `R`ed, or `U` for not sure — so a result is just a URL:
-## Site chrome: shared header and footer
+```
+https://validatorbeat.com/GGGRGG/?n=Ethereum%20Foundation's%20cluster
+```
-The **landing** (`/`), **assessment** (`/assess/`), and **methodology** (`/methodology/`) share one header and footer — [`components/layout/SiteHeader.tsx`](components/layout/SiteHeader.tsx) and [`SiteFooter.tsx`](components/layout/SiteFooter.tsx) (the landing nav, generalized).
+- **Link previews** — each code has a pre-rendered Open Graph card, so links unfurl with the pizza in X, Slack, Discord, and Telegram.
+- **Head to head** — add `&vs=&vn=` to show two results side by side. Anyone opening a shared result can take the assessment themselves and compare.
+- **Badge** — drop a live stage badge into your docs, README, or website:
-- **`SiteHeader`** adapts to the route: it hides the "Assess your validator" CTA on `/assess` (you're already there) and points "How it works" at `/#how` from inner pages. The logo mark reads the `--vb-*` tokens, so it follows the theme.
-- Both take a **`contentWidth`** prop so the inner content lines up with the page's main column (landing/methodology `1140`, assessment `1440`).
-- The assessment renders them as the top/bottom rows of the `100vh` `Shell`; `MainGrid` is `flex:1` and absorbs the remaining height, so the focus layout still fits one screen.
+ ```markdown
+ [](https://validatorbeat.com/GYYGGY/)
+ ```
-## v0.1 routes
+## Private by construction
-| Route | Purpose |
-|-------|---------|
-| `/` | Marketing landing page |
-| `/assess/` | Self-assessment (intro → 6 slices → results) |
-| `/GYRYGG` | Open a shared result (729 static pages + OG preview image) |
-| `/GYRYGG?n=Kody%27s%20Cluster` | Same, with optional display name (not in link preview) |
-| `/methodology/` | Framework and stage definitions |
-| `/operators/` | Redirects to `/` until operator registry ships |
+There is no backend. The site is a static export on GitHub Pages; scoring runs in your browser and nothing is submitted, stored, or tracked. Every one of the 1,296 possible results is a pre-built page. AI agents can run the same assessment from [`skill.md`](https://validatorbeat.com/skill.md) and [`llms.txt`](https://validatorbeat.com/llms.txt), both generated from the rubric so they can't drift.
-## Share codes
+## Improve the rubric
-Six characters: `G` green, `Y` yellow, `R` red — one per slice in order (Keys → Geo).
+The rubric is meant to be challenged. It lives in one place — [`lib/rubric/`](./lib/rubric) for stages, slices, and tips, and [`lib/assessment/questions.ts`](./lib/assessment/questions.ts) for question copy — and is fully unit-tested. Open an issue or PR with the scenario the current bands get wrong.
-## Link previews (OG / Twitter cards)
+For the operational controls behind each slice, see [ValOS, the Validator Operations Standard](https://lidofinance.github.io/valos/valos-spec.html).
-Share pages expose Open Graph tags and a per-code image at `/og/{code}.png` (e.g. `/og/GYRYGG.png`).
+## Develop
-**Local**
+Requires **Node.js 24+** (see [`.nvmrc`](./.nvmrc)).
```bash
-yarn build
-npx serve out
-```
-
-Then open a share URL (e.g. `http://localhost:3000/GYRYGG/`) and confirm meta tags:
-
-```bash
-curl -s http://localhost:3000/GYRYGG/ | grep -E 'og:|twitter:'
+yarn install
+yarn dev # http://localhost:3000
+yarn test # rubric unit tests
+yarn build # OG images + badges + agent files → static export in out/
```
-Open the image directly: `http://localhost:3000/og/GYRYGG.png`.
-
-**After deploy**
-
-| Tool | URL |
-|------|-----|
-| Meta Tags | https://metatags.io/ (paste your share URL) |
-| Facebook / LinkedIn debugger | https://developers.facebook.com/tools/debug/ |
-| opengraph.xyz | https://www.opengraph.xyz/ |
-
-Paste a live share link such as `https://validatorbeat.com/GYRYGG`. Debuggers cache previews — use “Scrape again” if you redeployed OG images.
-
-**Quick checks**
-
-- Image loads: `https://validatorbeat.com/og/GYRYGG.png`
-- `og:image` in page source matches that URL (must be absolute HTTPS)
-- Slack / Discord / iMessage: paste the URL in a draft message to see the unfurl
+Deployment, link-preview debugging, and theming notes are in [`docs/DEVELOPMENT.md`](./docs/DEVELOPMENT.md).
-## License
+## Credits
-Licensed under the Apache License, Version 2.0. See [LICENSE](./LICENSE).
+Built by [Obol](https://obol.org), in partnership with [Lido](https://lido.fi). Licensed under [Apache 2.0](./LICENSE).
diff --git a/components/assessment/AssessmentApp.tsx b/components/assessment/AssessmentApp.tsx
index d375536..f637f2c 100644
--- a/components/assessment/AssessmentApp.tsx
+++ b/components/assessment/AssessmentApp.tsx
@@ -1,13 +1,20 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { useRouter } from "next/router";
import { Pizza } from "@components/pizza/Pizza";
import { useAssessment } from "@hooks/useAssessment";
import { vbConfetti } from "@lib/confetti";
-import { SLICES, allAnswered, computeStage, decodeShareCode, shareCode } from "@lib/rubric";
+import { SLICES, STAGE_META, allAnswered, computeStage, decodeShareCode, shareCode } from "@lib/rubric";
import type { SliceColor, SliceId } from "@lib/rubric/types";
import { VbButton } from "@components/ui/VbButton";
-import { getShareUrl, shareNameFromQuery } from "@constants/index";
+import { SITE_NAME } from "@constants/index";
+import {
+ type CompareTarget,
+ compareAssessPath,
+ getShareUrl,
+ parseShareQuery,
+} from "@lib/share/share-url";
import { Box } from "@obolnetwork/obol-ui";
import { CONFETTI_BRAND } from "@lib/theme/tokens";
import {
@@ -16,6 +23,7 @@ import {
Legend,
pizzaOrigin,
} from "./Blockers";
+import { CompareCard } from "./Compare";
import { Intro } from "./Intro";
import { Question } from "./Question";
import { LevelUp, ResultHero, ShareModal } from "./Results";
@@ -38,8 +46,12 @@ type AssessmentAppProps = {
export function AssessmentApp({ initialShareCode }: AssessmentAppProps) {
const a = useAssessment();
+ const router = useRouter();
const [share, setShare] = useState(false);
const [shareName, setShareName] = useState("");
+ /** Opened from someone's share link and not yet edited — the result isn't the viewer's own. */
+ const [guest, setGuest] = useState(Boolean(initialShareCode));
+ const [compare, setCompare] = useState(null);
const active = a.atIntro || a.atResults ? null : a.current.id;
useEffect(() => {
@@ -52,7 +64,9 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) {
}
}
- setShareName(shareNameFromQuery(window.location.search));
+ const q = parseShareQuery(window.location.search);
+ if (initialShareCode) setShareName(q.name);
+ setCompare(q.compare);
return () => {
document.body.style.overflow = "";
@@ -61,6 +75,7 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) {
}, [initialShareCode]);
const choose = (id: SliceId, color: SliceColor) => {
+ setGuest(false);
if (color === "green") {
const idx = SLICES.findIndex((s) => s.id === id);
const o = pizzaOrigin(idx);
@@ -101,8 +116,30 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) {
return () => cancelAnimationFrame(frame);
}, [atResults, stage, answers, takeResultsConfetti]);
- const shareUrl =
- a.stage != null ? getShareUrl(shareCode(a.answers), shareName) : "";
+ const code = a.stage != null ? shareCode(a.answers) : "";
+ const shareUrl = code ? getShareUrl(code, shareName, compare) : "";
+
+ const compareAnswers = useMemo(
+ () => (compare ? decodeShareCode(compare.code) : null),
+ [compare],
+ );
+ const compareStage = compareAnswers ? computeStage(compareAnswers) : null;
+ const theirLabel = compare?.name || "Their setup";
+ const myLabel = guest ? shareName || "This setup" : "You";
+ const compareLine =
+ compare && a.stage != null && compareStage != null
+ ? `${guest ? myLabel : shareName || "My validator"} is ${STAGE_META[a.stage].name}; ${theirLabel} is ${STAGE_META[compareStage].name} — compared on Validator Beat.`
+ : undefined;
+
+ // Static pages can't know ?n=, so name the tab client-side once we can.
+ useEffect(() => {
+ if (!guest || !shareName || a.stage == null) return;
+ document.title = `${shareName} is ${STAGE_META[a.stage].name} | ${SITE_NAME}`;
+ }, [guest, shareName, a.stage]);
+
+ const takeItYourself = () => {
+ router.push(compareAssessPath({ code, name: shareName }));
+ };
return (
@@ -127,7 +164,14 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) {
{a.atIntro ? (
-
+
) : !a.atResults ? (
) : a.stage != null ? (
<>
-
+
+ {guest && (
+
+
+ {compareAnswers ? "Take it yourself →" : "Take it yourself & compare →"}
+
+ setShare(true)}>
+ Share this result
+
+
+ )}
+ {compareAnswers && (
+
+ )}
- {a.stage === 2 ? "Your perfect score" : "Your progress"}
+ {a.stage === 2 ? "Perfect score" : guest ? "Progress" : "Your progress"}
-
-
- setShare(true)}>Share my pizza →
-
- Start over
-
-
+
+ {!guest && (
+
+ setShare(true)}>
+ {compareAnswers ? "Share the head-to-head →" : "Share my pizza →"}
+
+
+ Start over
+
+
+ )}
>
) : null}
-
+ {/* On phones the pizza leads, except on a shared link, where the name and stage should. */}
+ setShare(false)}
/>
)}
diff --git a/components/assessment/Blockers.tsx b/components/assessment/Blockers.tsx
index 008e23f..0122fce 100644
--- a/components/assessment/Blockers.tsx
+++ b/components/assessment/Blockers.tsx
@@ -44,10 +44,13 @@ export function Legend() {
Green · no single point of failure
- Yellow · partially mitigated
+ Yellow · could take you offline, or partly mitigated
- Red · single point of failure
+ Red · one failure could slash you
+
+
+ Not sure · treated as a gap
);
diff --git a/components/assessment/Compare.tsx b/components/assessment/Compare.tsx
new file mode 100644
index 0000000..67ed692
--- /dev/null
+++ b/components/assessment/Compare.tsx
@@ -0,0 +1,105 @@
+import { Pizza } from "@components/pizza/Pizza";
+import { SLICES, STAGE_META, computeStage } from "@lib/rubric";
+import type { Answers, SliceColor } from "@lib/rubric/types";
+import { Box, Text } from "@obolnetwork/obol-ui";
+import { RiskDot, SectionLabel, risk } from "./stitches";
+
+const RANK: Record = { green: 2, yellow: 1, red: 0, unknown: 0 };
+
+type Side = { label: string; answers: Answers };
+
+type CompareCardProps = { left: Side; right: Side };
+
+/** Head-to-head of two results: two pizzas, a verdict, and the slices where they differ. */
+export function CompareCard({ left, right }: CompareCardProps) {
+ const ls = computeStage(left.answers);
+ const rs = computeStage(right.answers);
+ if (ls == null || rs == null) return null;
+
+ const diffs = SLICES.filter((s) => left.answers[s.id] !== right.answers[s.id]);
+ const ahead = (label: string, by: number) =>
+ `${label} ${label === "You" ? "are" : "is"} ${by === 1 ? "a stage" : "two stages"} ahead`;
+ const verdict =
+ ls === rs
+ ? `Both ${STAGE_META[ls].name}`
+ : ls > rs
+ ? ahead(left.label, ls - rs)
+ : ahead(right.label, rs - ls);
+
+ return (
+
+ Head to head
+
+
+ {[
+ { side: left, stage: ls },
+ { side: right, stage: rs },
+ ].map(({ side, stage }) => (
+
+
+
+ {side.label}
+
+
+ {STAGE_META[stage].name}
+
+
+ ))}
+
+
+ {verdict}
+ {diffs.length === 0 ? " — identical setups." : ` · ${diffs.length} of 6 slices differ`}
+
+ {diffs.length > 0 && (
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+ );
+}
diff --git a/components/assessment/Intro.tsx b/components/assessment/Intro.tsx
index e2ac0d6..e31c548 100644
--- a/components/assessment/Intro.tsx
+++ b/components/assessment/Intro.tsx
@@ -1,7 +1,12 @@
import { VbButton } from "@components/ui/VbButton";
+import { STAGE_META } from "@lib/rubric";
+import type { Stage } from "@lib/rubric/types";
import { StageLadder } from "./StageLadder";
import {
Eyebrow,
+ HeroCard,
+ HeroEyebrow,
+ HeroLine,
IntroGoal,
IntroGoalText,
IntroLede,
@@ -13,11 +18,25 @@ import {
type IntroProps = {
onStart: () => void;
+ /** Taking the assessment against someone's shared result. */
+ compare?: { name: string; stage: Stage };
};
-export function Intro({ onStart }: IntroProps) {
+export function Intro({ onStart, compare }: IntroProps) {
return (
+ {compare && (
+
+ Head to head
+
+ {compare.name} is{" "}
+
+ {STAGE_META[compare.stage].name}
+
+ . Answer for your own setup and see how you compare, slice by slice.
+
+
+ )}
A neutral resource for validator fault toleranceHow resilient is your validator setup?
diff --git a/components/assessment/Question.tsx b/components/assessment/Question.tsx
index 25dc661..2bbc9db 100644
--- a/components/assessment/Question.tsx
+++ b/components/assessment/Question.tsx
@@ -1,5 +1,6 @@
import { VbButton } from "@components/ui/VbButton";
import { QUESTIONS } from "@lib/assessment/questions";
+import type { QuestionOption } from "@lib/assessment/questions";
import { SLICES } from "@lib/rubric";
import type { SliceColor, SliceId } from "@lib/rubric/types";
import {
@@ -25,6 +26,13 @@ import {
RiskDot,
} from "./stitches";
+/** Offered on every question: an unverified gap has to be assumed open. */
+const NOT_SURE: QuestionOption = {
+ color: "unknown",
+ label: "Not sure",
+ sub: "Treated as a gap until you can check — your results will say how to find out.",
+};
+
type QuestionProps = {
sliceId: SliceId;
value?: SliceColor;
@@ -59,7 +67,7 @@ export function Question({
{data.q}
{data.helper && {data.helper}}
- {data.options.map((o) => {
+ {[...data.options, NOT_SURE].map((o) => {
const sel = value === o.color;
return (
onChoose(sliceId, o.color)}
+ css={o.color === "unknown" ? { marginTop: 4, borderStyle: "dashed" } : undefined}
>
diff --git a/components/assessment/Results.tsx b/components/assessment/Results.tsx
index 51a3088..9e238ff 100644
--- a/components/assessment/Results.tsx
+++ b/components/assessment/Results.tsx
@@ -1,16 +1,23 @@
import React, { forwardRef, useEffect, useRef, useState } from "react";
import { Pizza } from "@components/pizza/Pizza";
import { VbButton } from "@components/ui/VbButton";
-import { SHARE_NAME_MAX } from "@constants/index";
+import { DOWNTIME_CALCULATOR_URL } from "@constants/index";
+import { badgeSvg } from "@lib/share/badge-svg";
+import { copyText } from "@lib/share/clipboard";
import { downloadElementAsPng } from "@lib/share/download-image";
-import { SLICES, STAGE_META, getTip, shareCode } from "@lib/rubric";
+import { SHARE_NAME_MAX, badgeUrl } from "@lib/share/share-url";
+import { SLICES, STAGE_META, blockers, getTip, shareCode, sliceTarget } from "@lib/rubric";
import type { Answers, SliceColor, SliceId, Stage } from "@lib/rubric/types";
import type { SliceMeta } from "@lib/rubric/types";
import {
BrandAccent,
HeroCard,
+ EmbedActions,
+ EmbedBox,
+ EmbedHead,
HeroEyebrow,
HeroLine,
+ HeroOwner,
HeroProgress,
HeroStageKind,
HeroStageLine,
@@ -39,6 +46,9 @@ import {
ShareKind,
ShareLine,
ShareMeta,
+ ShareMore,
+ ShareMoreLink,
+ ShareOwner,
ShareResk,
ShareStage,
ShareTop,
@@ -71,44 +81,58 @@ const WHY_MAXED: Record = {
keyCustody: "No single compromise can sign with your stake.",
clientDiversity: "No supermajority-client fork can drag you in.",
infraDiversity: "No single provider can take your validator offline.",
- osDiversity: "No single OS compromise can reach all of your keys.",
- cpuDiversity: "No single CPU-level flaw can reach all of your keys.",
+ osDiversity: "No single OS compromise can reach enough key shares to sign.",
+ cpuDiversity: "No single CPU-level flaw can reach enough key shares to sign.",
geoDiversity: "No single region's outage can take your validator offline.",
};
-type ResultHeroProps = { stage: Stage; answers: Answers; ownerName?: string };
+type ResultHeroProps = {
+ stage: Stage;
+ answers: Answers;
+ ownerName?: string;
+ /** Viewing someone else's shared link rather than your own fresh result. */
+ guest?: boolean;
+};
-export function ResultHero({ stage, answers, ownerName }: ResultHeroProps) {
+export function ResultHero({ stage, answers, ownerName, guest = false }: ResultHeroProps) {
const m = STAGE_META[stage];
const tone = risk[m.tone];
+ const name = ownerName?.trim();
const greens = SLICES.filter((s) => answers[s.id] === "green").length;
- const outOfRed = SLICES.filter((s) => answers[s.id] && answers[s.id] !== "red").length;
+ const toGo = blockers(answers).length;
const progress =
stage === 2
? "All six slices maxed out"
- : `${outOfRed} of 6 slices secured · ${greens} maxed`;
+ : `${greens} of 6 maxed · ${toGo} to go for Stage ${stage + 1}`;
return (
-
- {ownerName?.trim() ? `${ownerName.trim()}'s result` : "Your result"}
-
+ {guest ? "Shared result" : name ? "Result for" : "Your result"}
+ {name && {name}}
{m.name}{m.kind}{progress}
- {STAGE_LINE[stage]}
+ {guest ? m.tagline + "." : STAGE_LINE[stage]}
);
}
-type LevelUpProps = { answers: Answers; stage: Stage };
+type LevelUpProps = { answers: Answers; stage: Stage; guest?: boolean };
+
+const FLAG: Record<1 | 2, { fix: string; check: string }> = {
+ 1: { fix: "Next → Stage 1", check: "Find out → Stage 1" },
+ 2: { fix: "Go for green → Stage 2", check: "Find out → Stage 2" },
+};
-export function LevelUp({ answers, stage }: LevelUpProps) {
+export function LevelUp({ answers, stage, guest = false }: LevelUpProps) {
const greens = SLICES.filter((s) => answers[s.id] === "green");
- const reds = SLICES.filter((s) => answers[s.id] === "red");
- const yellows = SLICES.filter((s) => answers[s.id] === "yellow");
+ // Stage 1 blockers first, then everything standing between you and Stage 2.
+ const open = SLICES.map((s) => ({ slice: s, color: answers[s.id]!, target: sliceTarget(s, answers[s.id]) }))
+ .filter((x): x is { slice: SliceMeta; color: SliceColor; target: 1 | 2 } => x.target != null)
+ .sort((x, y) => x.target - y.target);
+ const livenessOpen = open.some((x) => x.slice.kind === "liveness");
const Win = ({ slice }: { slice: SliceMeta }) => (
@@ -123,14 +147,14 @@ export function LevelUp({ answers, stage }: LevelUpProps) {
);
- const Next = ({ slice, color }: { slice: SliceMeta; color: SliceColor }) => (
-
+ const Next = ({ slice, color, target }: { slice: SliceMeta; color: SliceColor; target: 1 | 2 }) => (
+ {slice.label}
-
- {color === "red" ? "Next → Stage 1" : "Go for green → Stage 2"}
+
+ {color === "unknown" ? FLAG[target].check : FLAG[target].fix}
{getTip(slice.id, color)}
@@ -145,8 +169,10 @@ export function LevelUp({ answers, stage }: LevelUpProps) {
★
{stage === 2
- ? "You maxed out all six — perfect score"
- : `You've maxed ${greens.length} of 6 — top tier`}
+ ? guest
+ ? "All six maxed — perfect score"
+ : "You maxed out all six — perfect score"
+ : `${guest ? "Maxed" : "You've maxed"} ${greens.length} of 6 — top tier`}
{greens.map((s) => (
@@ -155,19 +181,29 @@ export function LevelUp({ answers, stage }: LevelUpProps) {
)}
- {reds.length + yellows.length > 0 && (
+ {open.length > 0 && (
- {greens.length > 0 ? "Keep climbing — your next wins" : "Your next wins"}
+ {greens.length > 0 ? "Keep climbing — next wins" : "Next wins"}
- {reds.map((s) => (
-
- ))}
- {yellows.map((s) => (
-
+ {open.map((x) => (
+
))}
+ {livenessOpen && (
+
+ Downtime may soon cost more than it does today:{" "}
+
+ EIP-7716
+ {" "}
+ would scale missed-attestation penalties with how many validators fail together —
+ roughly 78× today's rate when 10% of the network is down.{" "}
+
+ Estimate your exposure ↗
+
+
+ )}
)}
@@ -178,11 +214,13 @@ type ShareCardProps = {
answers: Answers;
stage: Stage;
shareUrl: string;
+ ownerName?: string;
};
export const ShareCard = forwardRef(
- function ShareCard({ answers, stage, shareUrl }, ref) {
+ function ShareCard({ answers, stage, shareUrl, ownerName }, ref) {
const m = STAGE_META[stage];
+ const name = ownerName?.trim();
const tone = risk[m.tone];
return (
@@ -190,7 +228,7 @@ export const ShareCard = forwardRef(
Validator Beat
- {shareUrl.replace(/^https?:\/\//, "")}
+ {shareUrl.replace(/^https?:\/\//, "").replace(/\?.*$/, "")}(
labelScale={0.72}
/>
- My validator setup is
+ {name ? (
+ <>
+ {name}
+ is
+ >
+ ) : (
+ My validator setup is
+ )}
{m.name}{m.kind}{m.shareLine}
@@ -220,22 +265,55 @@ type ShareModalProps = {
shareUrl: string;
ownerName: string;
onOwnerNameChange: (name: string) => void;
+ /** Set when this result was taken against someone else's; the link then carries both. */
+ compareLine?: string;
onClose: () => void;
};
+type CopyState = "idle" | "copied" | "failed";
+
+function useCopy() {
+ const [state, setState] = useState<{ key: string; status: CopyState }>({ key: "", status: "idle" });
+ const copy = async (key: string, text: string) => {
+ const ok = await copyText(text);
+ setState({ key, status: ok ? "copied" : "failed" });
+ setTimeout(() => setState({ key: "", status: "idle" }), 1800);
+ };
+ const label = (key: string, idle: string) =>
+ state.key !== key ? idle : state.status === "copied" ? "Copied ✓" : state.status === "failed" ? "Copy failed" : idle;
+ return { copy, label };
+}
+
export function ShareModal({
answers,
stage,
shareUrl,
ownerName,
onOwnerNameChange,
+ compareLine,
onClose,
}: ShareModalProps) {
- const [copied, setCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
const [nameError, setNameError] = useState(false);
+ const [canNativeShare, setCanNativeShare] = useState(false);
const cardRef = useRef(null);
const code = shareCode(answers);
+ const { copy, label } = useCopy();
+
+ const who = ownerName.trim();
+ const stageName = STAGE_META[stage].name;
+ const shareText = compareLine
+ ? `${compareLine} How resilient is yours?`
+ : who
+ ? `${who} is ${stageName} on Validator Beat. How resilient is yours?`
+ : `My validator setup is ${stageName} on Validator Beat. How resilient is yours?`;
+ const badgeAlt = `Validator Beat: ${stageName}`;
+ const badgeMarkdown = `[})](${shareUrl})`;
+ const badgeHtml = ``;
+
+ useEffect(() => {
+ setCanNativeShare(typeof navigator !== "undefined" && typeof navigator.share === "function");
+ }, []);
const handleNameChange = (value: string) => {
if (value.length > SHARE_NAME_MAX) {
@@ -254,23 +332,12 @@ export function ShareModal({
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
- const copy = () => {
- navigator.clipboard?.writeText(shareUrl).catch(() => {});
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- };
+ const openIntent = (url: string) => window.open(url, "_blank", "noopener,noreferrer");
+ const text = encodeURIComponent(shareText);
+ const url = encodeURIComponent(shareUrl);
- const postToX = () => {
- const who = ownerName.trim();
- const line = who
- ? `${who}'s validator setup is ${STAGE_META[stage].name} on Validator Beat. How resilient is yours?`
- : `My validator setup is ${STAGE_META[stage].name} on Validator Beat. How resilient is yours?`;
- const text = encodeURIComponent(line);
- window.open(
- `https://x.com/intent/post?text=${text}&url=${encodeURIComponent(shareUrl)}`,
- "_blank",
- "noopener,noreferrer",
- );
+ const nativeShare = () => {
+ navigator.share({ title: "Validator Beat", text: shareText, url: shareUrl }).catch(() => {});
};
const downloadImage = async () => {
@@ -291,23 +358,24 @@ export function ShareModal({
e.stopPropagation()}
role="dialog"
+ aria-modal="true"
aria-labelledby="share-title"
>
- Share your result
+ Share this result
✕
- Your name (optional, in URL only)
+ Name this setup (optional, shown on the page)) =>
handleNameChange(e.target.value)
}
- placeholder="e.g. Kody's Cluster"
+ placeholder="e.g. Ethereum Foundation's cluster"
maxLength={SHARE_NAME_MAX}
aria-invalid={nameError}
css={nameError ? { borderColor: risk.red } : undefined}
@@ -321,21 +389,71 @@ export function ShareModal({
answers={answers}
stage={stage}
shareUrl={shareUrl}
+ ownerName={ownerName}
/>
- Post to X →
+ {canNativeShare && Share…}
+ openIntent(`https://x.com/intent/post?text=${text}&url=${url}`)}
+ >
+ Post to X →
+
-
- {copied ? "Copied ✓" : "Copy link"}
+ copy("link", shareUrl)}>
+ {label("link", "Copy link")}
{downloading ? "Preparing…" : "Download image"}
+
+ Also on
+ openIntent(`https://farcaster.xyz/~/compose?text=${text}&embeds[]=${url}`)}
+ >
+ Farcaster
+
+ openIntent(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`)}
+ >
+ LinkedIn
+
+ openIntent(`https://t.me/share/url?url=${url}&text=${text}`)}
+ >
+ Telegram
+
+
+
+
+ Badge for your docs, README, or site
+
+
+
+ copy("md", badgeMarkdown)}>
+ {label("md", "Copy Markdown")}
+
+ copy("html", badgeHtml)}>
+ {label("html", "Copy HTML")}
+
+
+
- The link encodes your six colors ({code}) — anyone who opens it sees
- this exact result. Nothing is stored.
+ The link encodes your six answers ({code})
+ {compareLine ? " plus the result you compared against" : ""} — anyone who opens it
+ sees this exact result. Nothing is stored.
diff --git a/components/assessment/stitches.ts b/components/assessment/stitches.ts
index 689330d..6784f1e 100644
--- a/components/assessment/stitches.ts
+++ b/components/assessment/stitches.ts
@@ -7,12 +7,15 @@ export const risk = {
green: "var(--vb-green)",
yellow: "var(--vb-yellow)",
red: "var(--vb-red)",
+ unknown: "var(--vb-unknown)",
greenT: "var(--vb-green-t)",
yellowT: "var(--vb-yellow-t)",
redT: "var(--vb-red-t)",
+ unknownT: "var(--vb-unknown-t)",
greenB: "var(--vb-green-b)",
yellowB: "var(--vb-yellow-b)",
redB: "var(--vb-red-b)",
+ unknownB: "var(--vb-unknown-b)",
empty: "var(--vb-empty)",
emptyStroke: "var(--vb-empty-stroke)",
} as const;
@@ -246,6 +249,7 @@ export const RiskDot = styled(Box, {
green: { backgroundColor: risk.green },
yellow: { backgroundColor: risk.yellow },
red: { backgroundColor: risk.red },
+ unknown: { backgroundColor: risk.unknown },
empty: {
backgroundColor: risk.empty,
border: `1px solid ${risk.emptyStroke}`,
@@ -514,9 +518,18 @@ export const OptionButton = styled("button", {
green: {},
yellow: {},
red: {},
+ unknown: {},
},
},
compoundVariants: [
+ {
+ selected: true,
+ color: "unknown",
+ css: {
+ borderColor: risk.unknownB,
+ boxShadow: `0 0 0 1px ${risk.unknownB}`,
+ },
+ },
{
selected: true,
color: "green",
@@ -684,6 +697,16 @@ export const HeroEyebrow = styled(Text, {
color: "$textMiddle",
});
+export const HeroOwner = styled(Text, {
+ fontSize: 26,
+ fontWeight: "$bold",
+ letterSpacing: "-0.02em",
+ lineHeight: 1.15,
+ color: "$body",
+ marginTop: 6,
+ overflowWrap: "anywhere",
+});
+
export const HeroStageLine = styled(Box, {
display: "flex",
alignItems: "center",
@@ -828,6 +851,7 @@ export const UpCard = styled(Box, {
color: {
red: { borderLeft: `3px solid ${risk.red}` },
yellow: { borderLeft: `3px solid ${risk.yellow}` },
+ unknown: { borderLeft: `3px solid ${risk.unknown}` },
},
},
});
@@ -863,6 +887,7 @@ export const UpFlag = styled(Text, {
color: {
red: { color: risk.red },
yellow: { color: risk.yellow },
+ unknown: { color: risk.unknown },
},
},
});
@@ -932,6 +957,16 @@ export const ShareResk = styled(Text, {
color: "$textMiddle",
});
+export const ShareOwner = styled(Text, {
+ fontSize: 22,
+ fontWeight: "$bold",
+ letterSpacing: "-0.01em",
+ lineHeight: 1.15,
+ color: "$body",
+ marginBottom: 4,
+ overflowWrap: "anywhere",
+});
+
export const ShareStage = styled(Text, {
fontSize: 40,
fontWeight: "$bold",
@@ -1084,3 +1119,50 @@ export const ModalNote = styled(Text, {
fontWeight: "$semibold",
},
});
+
+/* ---- Share modal extras -------------------------------------------------- */
+
+export const ShareMore = styled(Box, {
+ display: "flex",
+ alignItems: "center",
+ flexWrap: "wrap",
+ gap: 14,
+ marginTop: 12,
+ fontSize: "$2",
+ color: "$textMiddle",
+});
+
+export const ShareMoreLink = styled(Box, {
+ all: "unset",
+ cursor: "pointer",
+ fontWeight: "$semibold",
+ color: "$body",
+ textDecoration: "underline",
+ textUnderlineOffset: 3,
+ "&:hover": { color: "var(--theme-brand)" },
+ "&:focus-visible": { outline: "2px solid var(--theme-brand)", outlineOffset: 2 },
+});
+
+export const EmbedBox = styled(Box, {
+ marginTop: 16,
+ padding: "12px 14px",
+ border: "1px dashed $bg05",
+ borderRadius: "$3",
+});
+
+export const EmbedHead = styled(Box, {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 12,
+ flexWrap: "wrap",
+ "& svg": { display: "block" },
+});
+
+export const EmbedActions = styled(Box, {
+ display: "flex",
+ gap: 10,
+ flexWrap: "wrap",
+ marginTop: 10,
+ "& button": { width: "auto" },
+});
diff --git a/components/landing/Landing.tsx b/components/landing/Landing.tsx
index 9091772..05fd0ec 100644
--- a/components/landing/Landing.tsx
+++ b/components/landing/Landing.tsx
@@ -309,14 +309,14 @@ const SLICE_DESC: Record = {
"How signing keys are held, and how many independent parties must cooperate to sign.",
clientDiversity: "Exposure to a bug in any single consensus or execution client.",
infraDiversity: "Concentration on a single cloud or hosting provider.",
- osDiversity: "Correlated risk from running a single operating system across the fleet.",
- cpuDiversity: "Correlated risk from running a single chipset across the fleet.",
+ osDiversity: "Whether one operating system's supply chain could reach enough key shares to sign.",
+ cpuDiversity: "Whether one CPU architecture's flaws could reach enough key shares to sign.",
geoDiversity: "Concentration in one region and exposure to power failure or natural disaster.",
};
/** Landing-voice detail per stage; naming comes from STAGE_META. */
const STAGE_DESC: Record = {
- 0: "At least one single point of failure remains. Most operators start here — the baseline.",
+ 0: "One failure — a leaked key, a client bug, a poisoned OS update — could still get you slashed. Most operators start here.",
1: "No single compromise of one machine, one team member, or one signer can produce a slashable message.",
2: "No single point of failure in the operator's infrastructure can take the validator offline. This is the end game.",
};
@@ -556,16 +556,17 @@ function LSides() {
Read any operator's profile in five seconds.
- Before you stake, not after an incident. A Stage and six colors give you insight into
- validator operations that the marketing page never will.
+ Before you stake, not after an incident: ask for their Validator Beat link or badge. A
+ Stage and six colors give you insight into validator operations that the marketing
+ page never will.
If you're an operatooorProof of work.
- Run the assessment, earn your stages, and show off your pizza wherever you list your
- product. Operators who have removed their single points of failure now have a way to show
+ Run the assessment, earn your stages, and show off your pizza — or embed the badge —
+ wherever you list your product. Operators who have removed their single points of failure now have a way to show
it.
@@ -587,21 +588,21 @@ function LValos() {
The standard behind the score
- Validator Beat and valOS
+ Validator Beat and ValOS
- Validator Beat is the public-facing who: which operators run
- validators, and how resilient their setups are. valOS, the Validator Operating
- Standard, is the technical how: a deep catalog of the controls and
- mitigations behind professional validator operations.
+ Validator Beat is the public-facing what: a quick, shareable read of
+ how resilient a validator setup is. ValOS, the Validator Operations Standard, is the
+ technical how: a deep catalog of the controls and mitigations behind
+ professional validator operations.
- A staker can quickly read an operator's stage and profile here. An operator doing
- the hard work and implementing the mitigations should dig into valOS. Follow valOS, and
+ A staker can read an operator's stage from a shared link or badge. An operator doing
+ the hard work and implementing the mitigations should dig into ValOS. Follow ValOS, and
you'll end up at Stage 2.
- Learn more about valOS
+ Learn more about ValOS
@@ -609,16 +610,16 @@ function LValos() {
Validator Beat
- The who — the public dashboard
+ The what — a stage anyone can read
- valOS
+ ValOS
- The how — the operating standard
+ The how — the operations standard
@@ -634,10 +635,10 @@ function LNeutral() {
Credibility
- A neutral dashboard, built in the open
+ A neutral yardstick, built in the open
- Validator Beat is co-authored by Obol and Lido. It is deliberately neutral and no single
- team owns the rubric. The methodology is public and is meant to be adopted, challenged,
+ Validator Beat is built by Obol, in partnership with Lido, the stewards of ValOS. It is
+ deliberately neutral and no single team owns the rubric. The methodology is public and is meant to be adopted, challenged,
and improved by the whole ecosystem.
@@ -677,7 +678,7 @@ function LClosing() {
Read the methodology·
- Explore valOS
+ Explore ValOS
diff --git a/components/layout/SiteFooter.tsx b/components/layout/SiteFooter.tsx
index 0051143..ecce1b7 100644
--- a/components/layout/SiteFooter.tsx
+++ b/components/layout/SiteFooter.tsx
@@ -57,7 +57,7 @@ export function SiteFooter({ contentWidth = 1140 }: SiteFooterProps) {
rel="noopener noreferrer"
css={navLink}
>
- valOS
+ ValOS
@@ -134,7 +136,7 @@ export function SiteHeader({ contentWidth = 1140 }: SiteHeaderProps) {
rel="noopener noreferrer"
css={navLink}
>
- valOS
+ ValOS
diff --git a/components/pizza/Pizza.tsx b/components/pizza/Pizza.tsx
index 3f3974b..6dba8d8 100644
--- a/components/pizza/Pizza.tsx
+++ b/components/pizza/Pizza.tsx
@@ -10,11 +10,13 @@ const PIZZA_FILL: Record = {
green: "var(--vb-green)",
yellow: "var(--vb-yellow)",
red: "var(--vb-red)",
+ unknown: "var(--vb-unknown)",
};
const PIZZA_RING: Record = {
green: "var(--theme-risk-green-ring)",
yellow: "var(--theme-risk-yellow-ring)",
red: "var(--theme-risk-red-ring)",
+ unknown: "var(--theme-risk-unknown-ring)",
};
const PIZZA_PLATE = "var(--vb-plate)";
const PIZZA_EMPTY = "var(--vb-empty)";
@@ -94,7 +96,7 @@ export function Pizza({
const frac = answered / SLICES.length;
const sliceDesc = SLICES.map(
- (s) => `${s.short} ${answers[s.id] ?? "unanswered"}`,
+ (s) => `${s.short} ${answers[s.id] === "unknown" ? "not sure" : (answers[s.id] ?? "unanswered")}`,
).join(", ");
const ariaLabel =
stage != null
@@ -112,7 +114,7 @@ export function Pizza({
>
{big && (
- {(["green", "yellow", "red"] as const).map((c) => (
+ {(["green", "yellow", "red", "unknown"] as const).map((c) => (
onSlice!(s.id) : undefined}
+ role={clickable ? "button" : undefined}
+ tabIndex={clickable ? 0 : undefined}
+ aria-label={clickable ? `Go to ${s.label}` : undefined}
+ onKeyDown={
+ clickable
+ ? (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onSlice!(s.id);
+ }
+ }
+ : undefined
+ }
>
)}
+ {col === "unknown" && (
+
+ ?
+
+ )}
{isActive && (
;
+type VbButtonProps = Omit, "variant" | "color"> & {
+ variant?: "primary" | "secondary";
+};
-/** obol-ui Button — Lido brand blue (#00A3FF), not obolGreen mint. */
-export function VbButton({ className, css, variant, ...props }: VbButtonProps) {
- const isPrimary = variant !== "secondary";
+/**
+ * obol-ui Button in the Validator Beat palette. obol-ui picks its look via the
+ * `color` variant (its `variant` prop is for nav/tx/wallet buttons), and its
+ * hover state swaps in its own text color — so both variants pin text color
+ * on hover to keep contrast in light and dark themes.
+ */
+export function VbButton({ className, css, variant = "primary", ...props }: VbButtonProps) {
+ const look =
+ variant === "primary"
+ ? {
+ backgroundColor: "var(--theme-brand)",
+ color: "var(--theme-text-on-brand)",
+ "&:hover": {
+ backgroundColor: "var(--theme-brand-hover)",
+ color: "var(--theme-text-on-brand)",
+ },
+ }
+ : {
+ backgroundColor: "$bg03",
+ color: "$body",
+ border: "2px solid $bg05",
+ "&:hover": { backgroundColor: "$bg04", color: "$body", border: "2px solid $bg05" },
+ };
return (
);
diff --git a/constants/index.ts b/constants/index.ts
index 590aad7..2dfe672 100644
--- a/constants/index.ts
+++ b/constants/index.ts
@@ -9,9 +9,6 @@ export const ASSESS_PATH = "/assess/";
export const METHODOLOGY_PATH = "/methodology/";
export const VALOS_URL = "https://lidofinance.github.io/valos/valos-spec.html";
export const GITHUB_URL = "https://github.com/ObolNetwork/validator-beat";
+/** Interactive EIP-7716 calculator — how correlated downtime penalties would scale. */
+export const DOWNTIME_CALCULATOR_URL = "https://validatordowntime.obol.org";
-export {
- getShareUrl,
- shareNameFromQuery,
- SHARE_NAME_MAX,
-} from "@lib/share/share-url";
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
new file mode 100644
index 0000000..74461f0
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,62 @@
+# Development
+
+Contributor notes that don't belong on the README. Architecture and conventions for coding agents live in [`CLAUDE.md`](../CLAUDE.md).
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `yarn dev` | Local dev server on :3000 |
+| `yarn test` | Rubric unit tests |
+| `yarn lint` | ESLint |
+| `yarn build` | OG images + badges + agent files, then static export to `out/` |
+| `yarn build:og` | Regenerate `public/og/*.png` and `public/badge/*.svg` only (~1 min) |
+| `yarn build:agents` | Regenerate `public/llms.txt` and `public/skill.md` from the rubric |
+| `yarn build:banner` | Regenerate the README header image (`docs/images/banner.png`, committed) |
+
+Optional: set `NEXT_PUBLIC_SITE_URL` in `.env.local` for share links (defaults to `https://validatorbeat.com`).
+
+## Routes
+
+| Route | Purpose |
+|-------|---------|
+| `/` | Landing page |
+| `/assess/` | Self-assessment (intro → 6 slices → results) |
+| `/assess/?vs=GYYGGY&vn=Name` | Take the assessment head-to-head against a result |
+| `/GYYGGY/` | A shared result (one static page + OG image + badge per valid code) |
+| `/GYYGGY/?n=Name&vs=GGGGGG&vn=Other` | Same, named, with an optional head-to-head |
+| `/badge/GYYGGY.svg` | README/website badge |
+| `/methodology/` | Framework and stage definitions |
+| `/operators/` | Redirects to `/` until the operator registry ships |
+
+Query parameters never reach link previews — OG images are static per code.
+
+## Deploy (GitHub Pages)
+
+Every push to `main` runs CI and, on success, deploys the static `out/` folder to GitHub Pages.
+
+1. **Settings → Pages → Build and deployment** — set **Source** to **GitHub Actions**.
+2. Production build env lives in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml):
+ - `NEXT_PUBLIC_BASE_PATH` — `/validator-beat` for `obolnetwork.github.io/validator-beat/`, empty for the custom domain.
+ - `NEXT_PUBLIC_SITE_URL` — absolute origin used in share links, OG tags, and badges.
+3. **Custom domain** (`validatorbeat.com`): point DNS at GitHub Pages and build with an empty `NEXT_PUBLIC_BASE_PATH`.
+
+## Link previews
+
+Share pages expose Open Graph tags and a per-code image at `/og/{code}.png`.
+
+```bash
+yarn build && npx serve out
+curl -s http://localhost:3000/GYYGGY/ | grep -E 'og:|twitter:'
+open http://localhost:3000/og/GYYGGY.png
+```
+
+After deploy, paste a live share URL into [metatags.io](https://metatags.io/), the [Facebook/LinkedIn debugger](https://developers.facebook.com/tools/debug/), or [opengraph.xyz](https://www.opengraph.xyz/). Debuggers cache previews — use "Scrape again" after redeploying images. `og:image` must be an absolute HTTPS URL.
+
+## Theming
+
+Edit [`styles/theme-tokens.css`](../styles/theme-tokens.css) to change colors (warm cream by default; an Obol-branded dark palette under `[data-theme="dark"]`). If you change pizza slice colors, also update [`lib/theme/tokens.ts`](../lib/theme/tokens.ts), which the pre-rendered OG images and badges use.
+
+## Site chrome
+
+The landing, assessment, and methodology share [`SiteHeader`](../components/layout/SiteHeader.tsx) and [`SiteFooter`](../components/layout/SiteFooter.tsx). The header hides the "Assess your validator" CTA on `/assess`, and both take a `contentWidth` prop to align with the page's main column (landing/methodology `1140`, assessment `1440`).
diff --git a/docs/PLAN.md b/docs/PLAN.md
deleted file mode 100644
index f32cd3e..0000000
--- a/docs/PLAN.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# Validator Beat — Project Plan (v0.1)
-
-> **Validator Beat v0.1** is a client-side self-assessment: six banded questions, live six-slice pizza, Stage 0–2 rollup, shareable result code. Nothing is submitted or stored. Design source: `design_handoff_validator_beat` (ported to Next.js 15 + TypeScript).
-
----
-
-## Decisions (locked in)
-
-| Topic | Choice |
-|-------|--------|
-| v0.1 product | **In-app self-assessment** (not external Typeform) |
-| Scoring | **Banded answers** → slice color → stage (`lib/rubric`, v0.1 rules) |
-| Operator directory | **Deferred** (v1.2 YAML registry + summary table later) |
-| Deploy | **Deferred** — static `out/`; GitHub Pages when ready |
-
----
-
-## v0.1 product
-
-Single-page assessment at `/`:
-
-- **Intro** → **6 questions** (420ms auto-advance) → **Results** + share modal
-- Persistent **pizza** panel (right column; top on mobile <880px)
-- **Methodology** at `/methodology/`
-- Share link: `/GYRYGG?n=Name` (729 static pages; `?n=` optional, not in OG preview)
-
-### Stage rules (v0.1)
-
-| Stage | Rule |
-|-------|------|
-| **0** | Any red slice |
-| **1** | No reds, not all green |
-| **2** | All six green |
-
-### Six slices
-
-Key Custody, Client Diversity, Infrastructure, OS, CPU Architecture, Geographic — see `/methodology/`.
-
-**Premise:** Four liveness slices assume per-validator **DVT / multiplexer** (nodes split duties).
-
----
-
-## Architecture
-
-```mermaid
-flowchart LR
- subgraph client [Browser only]
- Questions[questions.ts]
- Rubric[lib/rubric]
- UI[AssessmentApp + Pizza]
- end
- Questions --> UI
- User[User answers] --> Rubric
- Rubric --> UI
-```
-
-No API, no database, no operator YAML in v0.1.
-
----
-
-## Repo layout (v0.1)
-
-```
-validator-beat/
-├── styles/ # Handoff CSS tokens + layout
-├── lib/
-│ ├── rubric/ # Scoring engine + tests
-│ ├── assessment/questions.ts
-│ └── confetti.ts
-├── hooks/useAssessment.ts
-├── components/
-│ ├── assessment/ # Intro, Question, Results, shell
-│ └── pizza/Pizza.tsx
-├── pages/
-│ ├── index.tsx # Assessment
-│ ├── methodology.tsx
-│ └── operators/index.tsx # Redirect → /
-└── docs/PLAN.md
-```
-
----
-
-## Stack
-
-| Layer | Choice |
-|-------|--------|
-| Framework | Next.js 15 Pages Router, `output: "export"` |
-| Language | TypeScript 5.9 |
-| Styling | `@obolnetwork/obol-ui` Stitches (`components/assessment/stitches.ts`) + `theme-tokens.css` |
-| Tests | Jest (`lib/rubric`) |
-| UI kit | `@obolnetwork/obol-ui` (Button, Box, Text, Stitches styled components) |
-| Theme | [`styles/theme-tokens.css`](styles/theme-tokens.css) — editable palette (Lido forum–aligned) |
-
----
-
-## Deferred: operator registry phase
-
-Future work (DRAFT Validator Beat **v1.2**):
-
-- `data/operators/*.yaml`, Zod CI, admin merge pipeline
-- `/operators` summary table (obol-ui TableV3)
-- v1.2 stage matrix (per-slice minimums vs v0.1 rollup)
-- Automate `ethSecured`, client share drift
-
----
-
-## Deployment (later)
-
-- `yarn build` → `out/`
-- GitHub Pages + optional custom domain
-- Set `NEXT_PUBLIC_SITE_URL` for share links and OG (future)
-
----
-
-## Open questions
-
-1. Per-validator vs fleet-level wording for liveness slices
-2. Client diversity copy validation with operators
-3. `/r/[code]` OG preview images (post–v0.1)
-4. Download-image for share card
-
----
-
-## Implementation checklist
-
-- [x] scaffold-next-obol-ui
-- [x] plan-v01-handoff
-- [x] rubric-v01
-- [x] questions-v01
-- [x] styles-v01
-- [x] assessment-ui
-- [x] methodology-v01
-- [x] cleanup-routes
-- [ ] deploy-gh-pages (deferred)
-
----
-
-## References
-
-- Design handoff: `design_handoff_validator_beat/`
-- Internal spec (future registry): DRAFT Validator Beat v1.2 PDF
diff --git a/docs/images/banner.png b/docs/images/banner.png
new file mode 100644
index 0000000..770d837
Binary files /dev/null and b/docs/images/banner.png differ
diff --git a/lib/assessment/questions.ts b/lib/assessment/questions.ts
index b571d37..dacbc9f 100644
--- a/lib/assessment/questions.ts
+++ b/lib/assessment/questions.ts
@@ -25,36 +25,36 @@ export const QUESTIONS: Record = {
keyCustody: {
q: "What's the largest share of your signing power that any single point of failure could compromise, including both runtime keys and backups?",
helper:
- "A “point of failure” is anywhere your signing power can converge: a person, a team, a machine, a custodian, or even one piece of software (an RCE bug in your validator client could leak whatever key material it holds). With threshold signing, no single compromise can sign unless it controls more than ⅔ of the shares.",
+ "A “point of failure” is anywhere your signing power can converge: a person, a team, a machine, a custodian, or even one piece of software (an RCE bug in your validator client could leak whatever key material it holds). With threshold signing (typically ⅔ of shares), no single compromise can sign unless it reaches that threshold.",
options: [
{
color: "green",
- label: "No more than ⅓",
+ label: "⅓ or less",
sub: "Producing a valid signature would take at least two separate compromises.",
},
{
color: "yellow",
- label: "Between ⅓ and ⅔",
+ label: "More than ⅓, but less than ⅔",
sub: "One failure holds a meaningful share, but still can't sign on its own.",
},
{
color: "red",
- label: "More than ⅔",
- sub: "A single failure can leak your private keys.",
+ label: "⅔ or more",
+ sub: "A single failure holds enough to sign, or to leak your private keys.",
},
],
risk: "A single key-management compromise once forced one large operator to preemptively exit roughly 10% of all Ethereum validators — billions of dollars of stake withdrawn, tens of millions of dollars in opportunity cost, as a precaution because no one could rule out that whole signing keys had been exposed. With threshold signing split across independent parties — for example a multi-operator distributed validator, or HA remote signers like Dirk or Web3Signer fronted by Vouch or Vero — no single compromise can reconstruct a usable key.",
references: [
- { label: "valOS: Key Custody Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-keys" },
- { label: "valOS: Key Management", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-key-management" },
- { label: "valOS: Signature Management", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-signature-management" },
+ { label: "ValOS: Key Custody Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-keys" },
+ { label: "ValOS: Key Management", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-key-management" },
+ { label: "ValOS: Signature Management", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-signature-management" },
{ label: "Obol docs — distributed validators", url: "https://docs.obol.org/" },
],
},
clientDiversity: {
- q: "If a client bug forks onto an incorrect chain, what stops your validator from signing along with it?",
+ q: "If an execution or consensus client bug forks onto an incorrect chain, what stops your validator from signing along with it?",
helper:
- "Two safeguards work together. First, halt on disagreement: run several independent clients and configure your stack (Charon, Vero, or Vouch all support this) to stop attesting when they disagree, so a client bug becomes downtime instead of slashing. Second, watch the supermajority: if every client you run sits inside a ⅔ supermajority that forks together, your clients never disagree and the halt never triggers. Check clientdiversity.org for current client shares.",
+ "Two safeguards work together. First, halt on disagreement: run several independent clients and configure your stack (Charon, Vero, or Vouch all support this) to stop attesting when they disagree, so a client bug becomes downtime instead of slashing. Second, watch the supermajority: if every client you run sits inside a ⅔ supermajority that forks together, your clients never disagree and the halt never triggers. Count execution and consensus clients alike — execution-client supermajorities are the bigger risk on mainnet today. Check clientdiversity.org for current shares.",
options: [
{
color: "green",
@@ -69,14 +69,14 @@ export const QUESTIONS: Record = {
{
color: "red",
label: "Single client, or no refuse-to-attest safeguard",
- sub: "A consensus-client bug could drag you into signing an incorrect chain, with no software safety net to catch it.",
+ sub: "A single client bug could drag you into signing an incorrect chain, with no software safety net to catch it.",
},
],
risk: "On a recent public test network, a bug in one consensus client caused every validator using it to sign an incorrect chain. Because that client held a supermajority share of the testnet's validators, the result was mass slashing — the same pattern on mainnet would have destroyed billions of dollars of stake. Ethereum's slashing penalty already scales with how many validators are slashed in the same window, so a correlated event costs each affected validator far more than an isolated slashing would. Refuse-to-attest only saves you when your clients disagree; if they all run software in the bad supermajority, they fork in unison and the safeguard never fires.",
references: [
- { label: "valOS: Slashing Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-slashing" },
- { label: "valOS: Client Diversity", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-client-diversity" },
- { label: "valOS: Anti-Slashing DB", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-antislash-db" },
+ { label: "ValOS: Slashing Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-slashing" },
+ { label: "ValOS: Client Diversity", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-client-diversity" },
+ { label: "ValOS: Anti-Slashing DB", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-antislash-db" },
{ label: "clientdiversity.org", url: "https://clientdiversity.org/" },
{ label: "Obol docs — chain_split_halt", url: "https://docs.obol.org/" },
],
@@ -88,71 +88,67 @@ export const QUESTIONS: Record = {
options: [
{
color: "green",
- label: "Less than ⅓",
+ label: "⅓ or less",
sub: "No single provider's outage can take your validator offline.",
},
{
color: "yellow",
- label: "⅓ to ⅔",
- sub: "One provider going down would drop a large share of your nodes.",
- },
- {
- color: "red",
- label: "More than ⅔",
- sub: "One provider going down takes your validator offline.",
+ label: "More than ⅓",
+ sub: "One provider going down could drop you below your signing threshold and take your validator offline.",
},
],
risk: "A major hosting provider once suffered a security incident in which stored disk images may have been exposed. Any validator whose keystore and its decryption material lived on that provider's disks would have been at risk of complete key exfiltration in one stroke. Beyond breaches, single-provider outages routinely take large fractions of the network offline simultaneously — concentrating your nodes turns a vendor incident into your incident.",
references: [
- { label: "valOS: Infrastructure Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-infra" },
- { label: "valOS: Physically Distributed Infrastructure", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-distribute-hardware" },
- { label: "valOS: Utility Failure", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-protect-utilities" },
+ { label: "ValOS: Infrastructure Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-infra" },
+ { label: "ValOS: Physically Distributed Infrastructure", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-distribute-hardware" },
+ { label: "ValOS: Utility Failure", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-protect-utilities" },
{ label: "EIP-7716: Anti-correlation penalties", url: "https://eips.ethereum.org/EIPS/eip-7716" },
+ { label: "EIP-7716 downtime calculator", url: "https://validatordowntime.obol.org" },
],
},
osDiversity: {
- q: "Could a compromise of a single operating system expose all of your signing material?",
+ q: "Could a compromise of a single operating system reach enough of your signing material to sign?",
helper:
- "The concern is a compromise reaching every key at once, not uptime: an OS-level vulnerability, a poisoned package update, or a build-pipeline attack. Distinct Linux and Unix distributions count separately, so Ubuntu, Debian, Arch, NixOS, and macOS are each their own OS. Mixing them forces an attacker to break multiple independent supply chains to reach all of your signing material.",
+ "The concern is a compromise reaching every key at once, not uptime: an OS-level vulnerability, a poisoned package update, or a build-pipeline attack. Distinct Linux and Unix distributions count separately, so Ubuntu, Debian, Arch, NixOS, and macOS are each their own OS. What matters is how many key shares each OS holds: a 3-to-1 split across two distros in a four-node cluster still leaves one distro holding a signing threshold.",
options: [
{
color: "green",
- label: "No: signing material is split across two or more distinct distros",
- sub: "An OS-level compromise can only reach the share of your keys on that distro.",
+ label: "No: no single distro holds enough key shares to sign",
+ sub: "An OS-level compromise can only reach the shares on that distro, which isn't enough to produce a signature.",
},
{
color: "red",
- label: "Yes: all signing material runs on a single OS",
- sub: "One OS-level vulnerability or poisoned update could reach every key you hold.",
+ label: "Yes: one distro holds a signing threshold (or everything)",
+ sub: "One OS-level vulnerability or poisoned update could reach enough key material to sign.",
},
],
risk: "Operating systems regularly ship critical remote-code-execution disclosures, and their package managers and build pipelines have repeatedly been targeted by supply-chain attacks. A single poisoned update to a popular distro could backdoor every validator running it — mixing distros forces an attacker to compromise multiple independent supply chains to reach you.",
references: [
- { label: "valOS: Hacking Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-hacking" },
- { label: "valOS: Supply-chain Malware", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-protect-against-malware" },
- { label: "valOS: Third-party Software Updates", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-update-software" },
+ { label: "ValOS: Hacking Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-hacking" },
+ { label: "ValOS: Supply-chain Malware", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-protect-against-malware" },
+ { label: "ValOS: Third-party Software Updates", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-update-software" },
],
},
cpuDiversity: {
- q: "Could a compromise of a single CPU architecture expose all of your signing material?",
+ q: "Could a compromise of a single CPU architecture reach enough of your signing material to sign?",
helper:
- "Count by instruction set, not chip model: x86-64, ARM64, and RISC-V are the architectures that matter. Side-channel and speculative-execution flaws like Spectre and Meltdown are bound to one architecture, so splitting your signing material across two limits how far any single flaw can reach. RISC-V is barely available today, so two is the practical ceiling for most operators.",
+ "Count by instruction set, not chip model: x86-64, ARM64, and RISC-V are the architectures that matter. Side-channel and speculative-execution flaws like Spectre and Meltdown are bound to one architecture, so splitting key shares across two — with neither holding a signing threshold — limits how far any single flaw can reach. RISC-V is barely available today, so two is the practical ceiling for most operators.",
options: [
{
color: "green",
- label: "No: signing material is split across two or more architectures",
- sub: "Typically x86-64 plus ARM64 (Apple Silicon, AWS Graviton, Ampere), so a flaw in one architecture can only reach the keys running on it.",
+ label: "No: no single architecture holds enough key shares to sign",
+ sub: "Typically x86-64 plus ARM64 (Apple Silicon, AWS Graviton, Ampere), split so a flaw in one architecture can't reach a signing threshold.",
},
{
color: "red",
- label: "Yes: all signing material runs on a single architecture",
- sub: "Typically 100% x86-64, where one CPU-level vulnerability could reach every key.",
+ label: "Yes: one architecture holds a signing threshold (or everything)",
+ sub: "Typically 100% x86-64, where one CPU-level vulnerability could reach enough key material to sign.",
},
],
risk: "CPU architectures have repeatedly disclosed speculative-execution and side-channel vulnerabilities — Spectre, Meltdown, and a long tail of successors — that let one process read memory belonging to another process on the same machine, including, in principle, signing keys held by a co-located component. A second architecture across your fleet limits the blast radius of any single hardware-class vulnerability.",
references: [
- { label: "valOS: Hacking Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-hacking" },
- { label: "valOS: Physically Distributed Infrastructure", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-distribute-hardware" },
+ { label: "ValOS: Hacking Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-hacking" },
+ { label: "ValOS: Physically Distributed Infrastructure", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-distribute-hardware" },
],
},
geoDiversity: {
@@ -162,27 +158,23 @@ export const QUESTIONS: Record = {
options: [
{
color: "green",
- label: "Less than ⅓",
+ label: "⅓ or less",
sub: "No single region's outage can take your validator offline.",
},
{
color: "yellow",
- label: "⅓ to ⅔",
- sub: "One region going down would drop a large share of your nodes.",
- },
- {
- color: "red",
- label: "More than ⅔",
- sub: "One regional outage takes your validator offline.",
+ label: "More than ⅓",
+ sub: "One regional outage could drop you below your signing threshold and take your validator offline.",
},
],
risk: "Entire countries lose grid power for hours or days — the 2025 Iberian Peninsula blackout cut electricity to tens of millions of people across two countries simultaneously. Natural disasters, accidental cascading failures, and sovereign actions all show up as common-mode risk for validators concentrated in one country or region.",
references: [
- { label: "valOS: Downtime Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-downtime" },
- { label: "valOS: Physically Distributed Infrastructure", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-distribute-hardware" },
- { label: "valOS: Environmental Threat Protection", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-protect-from-environment" },
- { label: "valOS: Environmental Controls", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-controls-environment" },
+ { label: "ValOS: Downtime Risk", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-risks-downtime" },
+ { label: "ValOS: Physically Distributed Infrastructure", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-distribute-hardware" },
+ { label: "ValOS: Environmental Threat Protection", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-mit-protect-from-environment" },
+ { label: "ValOS: Environmental Controls", url: "https://lidofinance.github.io/valos/valos-spec.html#sec-controls-environment" },
{ label: "EIP-7716: Anti-correlation penalties", url: "https://eips.ethereum.org/EIPS/eip-7716" },
+ { label: "EIP-7716 downtime calculator", url: "https://validatordowntime.obol.org" },
],
},
};
diff --git a/lib/rubric/index.ts b/lib/rubric/index.ts
index ccd66de..c54d36d 100644
--- a/lib/rubric/index.ts
+++ b/lib/rubric/index.ts
@@ -1,46 +1,67 @@
-import type { Answers, SliceColor, SliceId, SliceMeta, Stage, StageMeta } from "./types";
+import type { Answers, SliceColor, SliceId, SliceKind, SliceMeta, Stage, StageMeta } from "./types";
import { COLORS } from "./types";
export { COLORS } from "./types";
-export type { Answers, SliceColor, SliceId, SliceMeta, Stage, StageMeta } from "./types";
+export type { Answers, SliceColor, SliceId, SliceKind, SliceMeta, Stage, StageMeta } from "./types";
-export const RUBRIC_VERSION = "0.1";
+export const RUBRIC_VERSION = "0.2";
+
+/**
+ * Colors carry one meaning everywhere: red = one failure could get you slashed,
+ * yellow = one failure could take you offline (or a partial safety gap).
+ * So the yes/no safety slices are green/red and the liveness slices green/yellow.
+ */
+const THREE_BANDS = [COLORS.green, COLORS.yellow, COLORS.red] as const;
+const SAFETY_BANDS = [COLORS.green, COLORS.red] as const;
+const LIVENESS_BANDS = [COLORS.green, COLORS.yellow] as const;
export const SLICES: SliceMeta[] = [
{
id: "keyCustody",
label: "Key Custody",
short: "Keys",
+ kind: "safety",
+ bands: THREE_BANDS,
why: "Concentrated private keys are a single point of failure — one compromise lets an attacker sign slashable messages with your entire stake.",
},
{
id: "clientDiversity",
label: "Client Diversity",
short: "Clients",
+ kind: "safety",
+ bands: THREE_BANDS,
why: "If a supermajority client forks onto a wrong chain, validators that follow it face mass correlated slashing; refusing to attest on disagreement turns that into mere downtime.",
},
{
id: "infraDiversity",
label: "Provider Diversity",
short: "Provider",
+ kind: "liveness",
+ bands: LIVENESS_BANDS,
why: "One hosting provider's outage or compromise can take every validator hosted there with it.",
},
{
id: "osDiversity",
label: "OS Diversity",
short: "OS",
+ kind: "safety",
+ bands: SAFETY_BANDS,
why: "An OS monoculture is a supply-chain risk: one poisoned update or zero-day could reach every node — and every key — at once.",
},
{
id: "cpuDiversity",
label: "CPU Architecture",
short: "CPU",
+ kind: "safety",
+ bands: SAFETY_BANDS,
why: "A CPU-architecture monoculture is a hardware-level supply-chain and side-channel risk — one flaw can reach keys on every machine you run.",
},
{
id: "geoDiversity",
label: "Geographic Diversity",
short: "Geo",
+ kind: "liveness",
+ bands: LIVENESS_BANDS,
why: "Nodes concentrated in one country or region share exposure to grid failures, natural disasters, and local policy shifts.",
},
];
@@ -54,8 +75,8 @@ export const STAGE_META: Record = {
0: {
name: "Stage 0",
kind: "Getting started",
- tagline: "Has a single point of failure",
- shareLine: "Has a single point of failure — for now.",
+ tagline: "One failure could get you slashed",
+ shareLine: "One failure could still get it slashed — for now.",
tone: COLORS.red,
},
1: {
@@ -74,36 +95,42 @@ export const STAGE_META: Record = {
},
};
-export const TIPS: Record = {
+export const TIPS: Record = {
keyCustody: {
- red: "Split your keys so no single party ever holds them in full — use distributed key generation, split backup mnemonics across 2+ parties, and sign through a multi-node setup (Dirk, Web3Signer, or a distributed validator).",
+ red: "Split your keys so no single party ever holds a signing threshold — use distributed key generation, split backup mnemonics across 2+ parties, and sign through a multi-node setup (Dirk, Web3Signer, or a distributed validator).",
yellow:
- "Distribute signing across 3+ independent parties (multi-operator DVT or distributed remote signers), backups included, so no single party controls more than ⅓ and losing any one of them doesn't threaten liveness.",
+ "Distribute signing across 3+ independent parties (multi-operator DVT or distributed remote signers), backups included, so no single party holds more than ⅓ and losing any one of them doesn't threaten liveness.",
+ unknown:
+ "Map every place key material lives — each signer, remote signer, backup, mnemonic, and custodian — and who can reach it. Until you can, assume one of them holds everything.",
},
clientDiversity: {
red: "Run 3+ independent clients with a refuse-to-attest-on-disagreement setup (multi-operator DVT or a Vero/Vouch-style multiplexer).",
yellow:
- "Add at least one minority client so your combined client share stays under ⅔ of the network.",
+ "Add at least one minority execution or consensus client so your combined client share stays under ⅔ of the network.",
+ unknown:
+ "Check which execution and consensus clients each node runs, and whether your stack halts when they disagree. Then compare against clientdiversity.org.",
},
infraDiversity: {
- red: "Move enough nodes off your largest provider to bring its share under ⅔.",
yellow:
"Spread hosting so no provider backs more than ⅓ of your active/active nodes — add providers or self-host a portion.",
+ unknown:
+ "List the provider behind every node that backs your stake (home and bare metal count as their own) and work out the largest share.",
},
osDiversity: {
- red: "Run a second, unrelated distro across your nodes (e.g. Debian or NixOS alongside Ubuntu) so one compromised OS supply chain can't reach all of your signing material.",
- yellow:
- "Add a third distinct distro so a single OS compromise can't threaten your validator's liveness either.",
+ red: "Run a second, unrelated distro (e.g. Fedora or NixOS alongside Ubuntu) and spread key shares so no single OS holds enough of them to sign.",
+ unknown:
+ "Inventory the distro on every machine that holds key shares or signs, and count how many shares each distro holds.",
},
cpuDiversity: {
- red: "Split your nodes across x86-64 and ARM64 hardware (Apple Silicon, AWS Graviton, Ampere) so one architecture-level flaw can't reach every key.",
- yellow:
- "Add a third ISA (RISC-V) once viable — aspirational for nearly all setups today.",
+ red: "Split your nodes across x86-64 and ARM64 hardware (Apple Silicon, AWS Graviton, Ampere) so no single architecture holds enough key shares to sign.",
+ unknown:
+ "Run `uname -m` on every machine that holds key shares or signs, and count how many shares each architecture holds.",
},
geoDiversity: {
- red: "Add nodes in a second country or region so one local disaster or outage can't take your validator offline.",
yellow:
- "Spread nodes across several regions so no single country or region backs more than ⅓ of your setup.",
+ "Spread nodes across three or more countries or regions so none backs more than ⅓ of your setup, and one local disaster or outage can't take your validator offline.",
+ unknown:
+ "List the country each node physically runs in — your cloud console shows the region — and work out the largest share.",
},
};
@@ -119,21 +146,40 @@ export function colorsArray(answers: Answers): (SliceColor | null)[] {
return SLICES.map((s) => sliceColor(answers, s.id));
}
-/** Stage 0 = any red · 1 = no reds · 2 = all green */
+/** Red and "not sure" both leave a single point of failure open. */
+export function isOpen(color: SliceColor | null | undefined): boolean {
+ return color === COLORS.red || color === COLORS.unknown;
+}
+
+/** Slices of one kind, in canonical order. */
+export function slicesOfKind(kind: SliceKind): SliceMeta[] {
+ return SLICES.filter((s) => s.kind === kind);
+}
+
+/**
+ * Stage 0 = a safety slice is red or not sure (one failure could slash you)
+ * Stage 1 = every safety slice is clear, but not all six green
+ * Stage 2 = all six green (no single failure can slash you or take you offline)
+ */
export function computeStage(answers: Answers): Stage | null {
- const cols = colorsArray(answers);
- if (cols.some((c) => c == null)) return null;
- if (cols.some((c) => c === COLORS.red)) return 0;
- if (cols.every((c) => c === COLORS.green)) return 2;
+ if (!allAnswered(answers)) return null;
+ if (slicesOfKind("safety").some((s) => isOpen(answers[s.id]))) return 0;
+ if (SLICES.every((s) => answers[s.id] === COLORS.green)) return 2;
return 1;
}
+/** The stage a slice's current answer is holding you back from, or null if it's maxed. */
+export function sliceTarget(slice: SliceMeta, color: SliceColor | null | undefined): Stage | null {
+ if (!color || color === COLORS.green) return null;
+ return slice.kind === "safety" && isOpen(color) ? 1 : 2;
+}
+
+/** Slices standing between the current stage and the next one. */
export function blockers(answers: Answers): SliceMeta[] {
- const cols = colorsArray(answers);
- if (cols.some((c) => c == null)) return [];
- const reds = SLICES.filter((s) => answers[s.id] === COLORS.red);
- if (reds.length) return reds;
- return SLICES.filter((s) => answers[s.id] !== COLORS.green);
+ const stage = computeStage(answers);
+ if (stage == null || stage === 2) return [];
+ const next = (stage + 1) as Stage;
+ return SLICES.filter((s) => sliceTarget(s, answers[s.id]) === next);
}
export function getTip(sliceId: SliceId, color: SliceColor): string | null {
@@ -141,29 +187,46 @@ export function getTip(sliceId: SliceId, color: SliceColor): string | null {
return TIPS[sliceId]?.[color] ?? null;
}
-/** Six-letter share code, e.g. GYRYGG */
+const CODE_LETTER: Record = {
+ green: "G",
+ yellow: "Y",
+ red: "R",
+ unknown: "U",
+};
+
+const LETTER_COLOR: Record = Object.fromEntries(
+ Object.entries(CODE_LETTER).map(([color, letter]) => [letter, color as SliceColor]),
+);
+
+/** Answers a slice can hold: its banded options plus "not sure". */
+export function sliceChoices(slice: SliceMeta): SliceColor[] {
+ return [...slice.bands, COLORS.unknown];
+}
+
+/** Six-letter share code, e.g. GYRUGG — one letter per slice in SLICES order. */
export function shareCode(answers: Answers): string {
- const map: Record = {
- green: "G",
- yellow: "Y",
- red: "R",
- };
return colorsArray(answers)
- .map((c) => (c ? map[c] : "_"))
+ .map((c) => (c ? CODE_LETTER[c] : "_"))
.join("");
}
+/** Decode a complete share code; rejects answers a slice's question can't produce. */
export function decodeShareCode(code: string): Answers | null {
- if (!/^[GYR_]{6}$/.test(code)) return null;
- const colorMap: Record = {
- G: COLORS.green,
- Y: COLORS.yellow,
- R: COLORS.red,
- };
+ if (code.length !== SLICES.length) return null;
const answers: Answers = {};
- SLICES.forEach((s, i) => {
- const ch = code[i];
- if (ch in colorMap) answers[s.id] = colorMap[ch];
- });
+ for (let i = 0; i < SLICES.length; i++) {
+ const color = LETTER_COLOR[code[i]];
+ if (!color || !sliceChoices(SLICES[i]).includes(color)) return null;
+ answers[SLICES[i].id] = color;
+ }
return answers;
}
+
+/** Every valid share code — one static share page and OG image each. */
+export function allShareCodes(): string[] {
+ return SLICES.reduce(
+ (prefixes, slice) =>
+ prefixes.flatMap((p) => sliceChoices(slice).map((c) => p + CODE_LETTER[c])),
+ [""],
+ );
+}
diff --git a/lib/rubric/rubric.test.ts b/lib/rubric/rubric.test.ts
index b5c4fa8..f775b51 100644
--- a/lib/rubric/rubric.test.ts
+++ b/lib/rubric/rubric.test.ts
@@ -1,70 +1,133 @@
+import { QUESTIONS } from "@lib/assessment/questions";
import {
COLORS,
SLICES,
STAGE_META,
+ allShareCodes,
blockers,
computeStage,
decodeShareCode,
+ getTip,
shareCode,
+ sliceChoices,
} from "./index";
-import type { Answers } from "./types";
+import type { Answers, SliceColor } from "./types";
-const allGreen = (): Answers =>
- Object.fromEntries(SLICES.map((s) => [s.id, COLORS.green])) as Answers;
+const all = (color: SliceColor): Answers =>
+ Object.fromEntries(SLICES.map((s) => [s.id, color])) as Answers;
-const allYellow = (): Answers =>
- Object.fromEntries(SLICES.map((s) => [s.id, COLORS.yellow])) as Answers;
-
-describe("rubric v0.1", () => {
+describe("stages", () => {
it("all green → Stage 2", () => {
- expect(computeStage(allGreen())).toBe(2);
+ expect(computeStage(all(COLORS.green))).toBe(2);
});
- it("one red → Stage 0", () => {
- const a = allYellow();
+ it("a red safety slice → Stage 0", () => {
+ const a = all(COLORS.green);
a.keyCustody = COLORS.red;
expect(computeStage(a)).toBe(0);
});
- it("all yellow, no red → Stage 1", () => {
- expect(computeStage(allYellow())).toBe(1);
+ it("liveness slices only block Stage 2", () => {
+ const a = all(COLORS.green);
+ a.geoDiversity = COLORS.yellow;
+ a.infraDiversity = COLORS.yellow;
+ expect(computeStage(a)).toBe(1);
+ });
+
+ it("not sure counts as red", () => {
+ const a = all(COLORS.green);
+ a.osDiversity = COLORS.unknown;
+ expect(computeStage(a)).toBe(0);
+ a.osDiversity = COLORS.green;
+ a.geoDiversity = COLORS.unknown;
+ expect(computeStage(a)).toBe(1);
+ });
+
+ it("yellow safety slices are still Stage 1", () => {
+ const a = all(COLORS.green);
+ a.keyCustody = COLORS.yellow;
+ a.clientDiversity = COLORS.yellow;
+ expect(computeStage(a)).toBe(1);
});
it("incomplete → null stage", () => {
expect(computeStage({ keyCustody: COLORS.green })).toBeNull();
});
- it("shareCode encodes six slice colors", () => {
- expect(shareCode(allGreen())).toBe("GGGGGG");
+ it("STAGE_META names and tones line up with stage numbers", () => {
+ ([0, 1, 2] as const).forEach((n) => {
+ expect(STAGE_META[n].name).toBe(`Stage ${n}`);
+ expect(STAGE_META[n].shareLine.length).toBeGreaterThan(0);
+ });
+ expect(STAGE_META[0].tone).toBe(COLORS.red);
+ expect(STAGE_META[1].tone).toBe(COLORS.yellow);
+ expect(STAGE_META[2].tone).toBe(COLORS.green);
+ });
+});
+
+describe("blockers", () => {
+ it("at Stage 0, lists only the safety slices holding you back", () => {
+ const a = all(COLORS.green);
+ a.cpuDiversity = COLORS.red;
+ a.geoDiversity = COLORS.yellow;
+ expect(blockers(a).map((s) => s.id)).toEqual(["cpuDiversity"]);
+ });
+
+ it("at Stage 1, lists every slice that isn't green", () => {
+ const a = all(COLORS.green);
+ a.keyCustody = COLORS.yellow;
+ a.geoDiversity = COLORS.unknown;
+ expect(blockers(a).map((s) => s.id)).toEqual(["keyCustody", "geoDiversity"]);
+ });
+
+ it("is empty at Stage 2", () => {
+ expect(blockers(all(COLORS.green))).toEqual([]);
+ });
+});
+
+describe("share codes", () => {
+ it("encodes one letter per slice", () => {
+ expect(shareCode(all(COLORS.green))).toBe("GGGGGG");
const mixed: Answers = {
keyCustody: COLORS.green,
clientDiversity: COLORS.yellow,
- infraDiversity: COLORS.red,
- osDiversity: COLORS.green,
- cpuDiversity: COLORS.yellow,
- geoDiversity: COLORS.green,
+ infraDiversity: COLORS.yellow,
+ osDiversity: COLORS.unknown,
+ cpuDiversity: COLORS.red,
+ geoDiversity: COLORS.yellow,
};
- expect(shareCode(mixed)).toBe("GYRGYG");
+ expect(shareCode(mixed)).toBe("GYYURY");
+ expect(decodeShareCode("GYYURY")).toEqual(mixed);
});
- it("blockers lists reds when Stage 0", () => {
- const a = allGreen();
- a.infraDiversity = COLORS.red;
- expect(blockers(a).map((s) => s.id)).toEqual(["infraDiversity"]);
+ it("rejects bands a question can't produce", () => {
+ expect(decodeShareCode("GGGYGG")).toBeNull(); // OS has no yellow band
+ expect(decodeShareCode("GGRGGG")).toBeNull(); // Provider has no red band
+ expect(decodeShareCode("GGGGG")).toBeNull();
+ expect(decodeShareCode("GGGGGX")).toBeNull();
});
- it("decodeShareCode round-trips", () => {
- const code = shareCode(allGreen());
- expect(decodeShareCode(code)).toEqual(allGreen());
+ it("allShareCodes covers exactly the valid codes", () => {
+ const codes = allShareCodes();
+ const expected = SLICES.reduce((n, s) => n * sliceChoices(s).length, 1);
+ expect(codes).toHaveLength(expected);
+ expect(new Set(codes).size).toBe(expected);
+ codes.forEach((c) => expect(decodeShareCode(c)).not.toBeNull());
});
+});
- it("STAGE_META names and tones line up with stage numbers", () => {
- ([0, 1, 2] as const).forEach((n) => {
- expect(STAGE_META[n].name).toBe(`Stage ${n}`);
- expect(STAGE_META[n].shareLine.length).toBeGreaterThan(0);
+describe("questions stay in sync with the rubric", () => {
+ it("each question offers exactly its slice's bands, best → worst", () => {
+ SLICES.forEach((s) => {
+ expect(QUESTIONS[s.id].options.map((o) => o.color)).toEqual([...s.bands]);
+ });
+ });
+
+ it("every non-green answer has a tip", () => {
+ SLICES.forEach((s) => {
+ sliceChoices(s)
+ .filter((c) => c !== COLORS.green)
+ .forEach((c) => expect(getTip(s.id, c)).toBeTruthy());
});
- expect(STAGE_META[0].tone).toBe(COLORS.red);
- expect(STAGE_META[1].tone).toBe(COLORS.yellow);
- expect(STAGE_META[2].tone).toBe(COLORS.green);
});
});
diff --git a/lib/rubric/types.ts b/lib/rubric/types.ts
index 5834dc3..45eaf44 100644
--- a/lib/rubric/types.ts
+++ b/lib/rubric/types.ts
@@ -2,6 +2,8 @@ export const COLORS = {
green: "green",
yellow: "yellow",
red: "red",
+ /** "Not sure" — scored like red, because an unverified gap has to be assumed open. */
+ unknown: "unknown",
} as const;
export type SliceColor = (typeof COLORS)[keyof typeof COLORS];
@@ -14,6 +16,12 @@ export type SliceId =
| "cpuDiversity"
| "geoDiversity";
+/**
+ * Which failure mode a slice guards against. Safety slices gate Stage 1
+ * (no single failure can get you slashed); liveness slices only gate Stage 2.
+ */
+export type SliceKind = "safety" | "liveness";
+
export type Answers = Partial>;
export type Stage = 0 | 1 | 2;
@@ -22,6 +30,9 @@ export type SliceMeta = {
id: SliceId;
label: string;
short: string;
+ kind: SliceKind;
+ /** Banded answers the question offers, best → worst. "Not sure" is always available on top. */
+ bands: readonly SliceColor[];
why: string;
};
diff --git a/lib/share/badge-svg.ts b/lib/share/badge-svg.ts
new file mode 100644
index 0000000..34c7462
--- /dev/null
+++ b/lib/share/badge-svg.ts
@@ -0,0 +1,50 @@
+import { SLICES, STAGE_META, computeStage } from "@lib/rubric";
+import type { Answers } from "@lib/rubric/types";
+import { PIZZA_FILL } from "@lib/theme/tokens";
+import { escapeXml } from "@lib/share/og-card";
+
+const H = 20;
+const LEFT_W = 96;
+const RIGHT_W = 78;
+const RAD = Math.PI / 180;
+
+/** A mini six-slice pizza, r=6, centred at (cx, 10). */
+function miniPizza(answers: Answers, cx: number): string {
+ const r = 6;
+ const cy = H / 2;
+ return SLICES.map((s, i) => {
+ const a0 = (-90 + i * 60 + 3) * RAD;
+ const a1 = (-90 + (i + 1) * 60 - 3) * RAD;
+ const col = answers[s.id];
+ const fill = col ? PIZZA_FILL[col] : "#555";
+ return ``;
+ }).join("");
+}
+
+/**
+ * Shields-style README/website badge: "Validator Beat | 🍕 Stage N".
+ * Pre-rendered per share code into public/badge/ by scripts/generate-og-images.ts.
+ */
+export function badgeSvg(answers: Answers): string {
+ const stage = computeStage(answers);
+ const stageName = stage == null ? "In progress" : STAGE_META[stage].name;
+ const tone = stage == null ? "#555" : PIZZA_FILL[STAGE_META[stage].tone];
+ const w = LEFT_W + RIGHT_W;
+ const label = `Validator Beat: ${stageName}`;
+ return ``;
+}
diff --git a/lib/share/clipboard.ts b/lib/share/clipboard.ts
new file mode 100644
index 0000000..83cee9d
--- /dev/null
+++ b/lib/share/clipboard.ts
@@ -0,0 +1,22 @@
+/** Copy text, falling back to a hidden textarea where the async Clipboard API is unavailable or blocked. */
+export async function copyText(text: string): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ const el = document.createElement("textarea");
+ el.value = text;
+ el.setAttribute("readonly", "");
+ el.style.position = "fixed";
+ el.style.opacity = "0";
+ document.body.appendChild(el);
+ el.select();
+ try {
+ return document.execCommand("copy");
+ } catch {
+ return false;
+ } finally {
+ document.body.removeChild(el);
+ }
+ }
+}
diff --git a/lib/share/landing-og-svg.ts b/lib/share/landing-og-svg.ts
index 1bc3ae1..b2b11ff 100644
--- a/lib/share/landing-og-svg.ts
+++ b/lib/share/landing-og-svg.ts
@@ -25,10 +25,10 @@ export function landingOgSvg(
const colors: SliceColor[] = [
"green",
"yellow",
- "red",
- "green",
"yellow",
"green",
+ "red",
+ "green",
];
return ogShell(`
diff --git a/lib/share/og-card.ts b/lib/share/og-card.ts
index ab036a5..d97c7be 100644
--- a/lib/share/og-card.ts
+++ b/lib/share/og-card.ts
@@ -1,7 +1,7 @@
/**
* Shared building blocks for the dark OG-card system (1200×630).
*
- * Both the landing card (lib/share/landing-og-svg.ts) and the 729 share
+ * Both the landing card (lib/share/landing-og-svg.ts) and the per-code share
* cards (lib/share/pizza-og-svg.ts) compose these pieces so layout, palette,
* and the pizza rendering stay pixel-identical across the whole OG surface.
* Cards are rendered to PNG at build time by scripts/generate-og-images.ts.
@@ -40,8 +40,14 @@ export const OG_STAGE_COLOR: Record = {
2: PIZZA_FILL.green,
};
+/** Escape text for SVG/XML element content and quoted attribute values. */
export function escapeXml(s: string): string {
- return s.replace(/&/g, "&").replace(//g, ">");
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
}
const RAD = Math.PI / 180;
@@ -106,6 +112,7 @@ export function ogPizza(o: OgPizzaOpts): string {
: ` stroke-dasharray="5 4" stroke="${OG_EMPTY_STROKE}" stroke-width="1.5"`;
return `
+ ${col === "unknown" ? (() => { const [qx, qy] = polar(cx, cy, r * 0.64, mid); return `?`; })() : ""}
${escapeXml(s.short)}`;
}).join("");
diff --git a/lib/share/og-meta.ts b/lib/share/og-meta.ts
index 0e66775..a002d16 100644
--- a/lib/share/og-meta.ts
+++ b/lib/share/og-meta.ts
@@ -12,7 +12,7 @@ export function shareOgMeta(answers: Answers) {
const sliceLine = SLICES.map((s) => {
const c = answers[s.id];
- return c ? `${s.short}: ${c}` : `${s.short}: —`;
+ return c ? `${s.short}: ${c === "unknown" ? "not sure" : c}` : `${s.short}: —`;
}).join(" · ");
return {
diff --git a/lib/share/share-url.ts b/lib/share/share-url.ts
index 768f0fa..df580f7 100644
--- a/lib/share/share-url.ts
+++ b/lib/share/share-url.ts
@@ -1,23 +1,56 @@
import { SITE_URL } from "@constants/index";
+import { decodeShareCode } from "@lib/rubric";
-export const SHARE_NAME_MAX = 25;
+export const SHARE_NAME_MAX = 40;
-export function shareNameFromQuery(search: string): string {
- if (!search) return "";
- const raw = new URLSearchParams(search).get("n");
+/** Another result to compare against, carried in the URL as ?vs=CODE&vn=Name. */
+export type CompareTarget = { code: string; name: string };
+
+function cleanName(raw: string | null): string {
if (!raw) return "";
- try {
- const decoded = decodeURIComponent(raw);
- return decoded.slice(0, SHARE_NAME_MAX);
- } catch {
- return raw.slice(0, SHARE_NAME_MAX);
- }
+ return raw.trim().slice(0, SHARE_NAME_MAX);
+}
+
+export type ShareQuery = { name: string; compare: CompareTarget | null };
+
+/** Parse ?n= (owner name) and ?vs=/&vn= (comparison) from a location.search string. */
+export function parseShareQuery(search: string): ShareQuery {
+ const q = new URLSearchParams(search);
+ const vs = (q.get("vs") ?? "").toUpperCase();
+ return {
+ name: cleanName(q.get("n")),
+ compare: decodeShareCode(vs) ? { code: vs, name: cleanName(q.get("vn")) } : null,
+ };
+}
+
+function siteBase(): string {
+ return SITE_URL.replace(/\/$/, "");
+}
+
+function query(params: Record): string {
+ const q = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => {
+ if (v) q.set(k, v);
+ });
+ const s = q.toString();
+ return s ? `?${s}` : "";
+}
+
+/** Absolute share URL for a result, optionally naming it and pinning a comparison. */
+export function getShareUrl(code: string, name?: string, compare?: CompareTarget | null): string {
+ return `${siteBase()}/${code}${query({
+ n: cleanName(name ?? null),
+ vs: compare?.code,
+ vn: compare ? cleanName(compare.name) : undefined,
+ })}`;
+}
+
+/** In-app route (basePath-relative, for next/link or router.push) to take the assessment against a result. */
+export function compareAssessPath(target: CompareTarget): string {
+ return `/assess/${query({ vs: target.code, vn: cleanName(target.name) })}`;
}
-export function getShareUrl(code: string, name?: string): string {
- const base = SITE_URL.replace(/\/$/, "");
- const path = `${base}/${code}`;
- const trimmed = name?.trim().slice(0, SHARE_NAME_MAX);
- if (!trimmed) return path;
- return `${path}?n=${encodeURIComponent(trimmed)}`;
+/** Absolute URL of the pre-rendered README/website badge for a result. */
+export function badgeUrl(code: string): string {
+ return `${siteBase()}/badge/${code}.svg`;
}
diff --git a/lib/theme/share-codes.ts b/lib/theme/share-codes.ts
deleted file mode 100644
index 1a39edc..0000000
--- a/lib/theme/share-codes.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-const LETTERS = ["G", "Y", "R"] as const;
-
-/** All 729 six-letter G/Y/R share codes. */
-export function allShareCodes(): string[] {
- const codes: string[] = [];
- for (let a = 0; a < 3; a++) {
- for (let b = 0; b < 3; b++) {
- for (let c = 0; c < 3; c++) {
- for (let d = 0; d < 3; d++) {
- for (let e = 0; e < 3; e++) {
- for (let f = 0; f < 3; f++) {
- codes.push(
- LETTERS[a] + LETTERS[b] + LETTERS[c] + LETTERS[d] + LETTERS[e] + LETTERS[f],
- );
- }
- }
- }
- }
- }
- }
- return codes;
-}
diff --git a/lib/theme/share-path.ts b/lib/theme/share-path.ts
deleted file mode 100644
index 79acd44..0000000
--- a/lib/theme/share-path.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/** Six-letter G/Y/R share code in the URL path, e.g. /GYRYGG/ */
-export const SHARE_CODE_PATH = /^\/([gyr]{6})\/?$/i;
-
-export function shareCodeFromPath(pathname: string): string | null {
- const m = pathname.match(SHARE_CODE_PATH);
- return m ? m[1].toUpperCase() : null;
-}
diff --git a/lib/theme/tokens.ts b/lib/theme/tokens.ts
index cef6247..e755a09 100644
--- a/lib/theme/tokens.ts
+++ b/lib/theme/tokens.ts
@@ -10,12 +10,14 @@ export const PIZZA_FILL: Record = {
green: "#3a9e80",
yellow: "#cf9a3a",
red: "#c46044",
+ unknown: "#7c8595",
};
export const PIZZA_RING: Record = {
green: "#1f7a5e",
yellow: "#a8761f",
red: "#a64428",
+ unknown: "#5c6474",
};
export const PIZZA_PLATE = "#fffdf7";
diff --git a/package.json b/package.json
index 66579e3..419f7f9 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"dev": "next dev",
"build:og": "ts-node -r tsconfig-paths/register --compiler-options '{\"module\":\"CommonJS\"}' scripts/generate-og-images.ts",
"build:icons": "ts-node -r tsconfig-paths/register --compiler-options '{\"module\":\"CommonJS\"}' scripts/generate-icons.ts",
+ "build:banner": "ts-node -r tsconfig-paths/register --compiler-options '{\"module\":\"CommonJS\"}' scripts/generate-readme-banner.ts",
"build:agents": "ts-node -r tsconfig-paths/register --compiler-options '{\"module\":\"CommonJS\"}' scripts/generate-agent-files.ts",
"build": "yarn build:og && yarn build:agents && next build",
"start": "next start",
diff --git a/pages/[code].tsx b/pages/[code].tsx
index bc99701..77437ab 100644
--- a/pages/[code].tsx
+++ b/pages/[code].tsx
@@ -1,8 +1,7 @@
import { AssessmentApp } from "@components/assessment/AssessmentApp";
import { SITE_URL } from "@constants/index";
-import { computeStage, decodeShareCode } from "@lib/rubric";
+import { allShareCodes, computeStage, decodeShareCode } from "@lib/rubric";
import { shareOgMeta } from "@lib/share/og-meta";
-import { allShareCodes } from "@lib/theme/share-codes";
import type { GetStaticPaths, GetStaticProps } from "next";
type SharePageProps = {
diff --git a/pages/methodology.tsx b/pages/methodology.tsx
index 9062e0c..61789f2 100644
--- a/pages/methodology.tsx
+++ b/pages/methodology.tsx
@@ -2,15 +2,15 @@ import Link from "next/link";
import { AnchorHeading } from "@components/methodology/AnchorHeading";
import { SiteHeader } from "@components/layout/SiteHeader";
import { SiteFooter } from "@components/layout/SiteFooter";
-import { ASSESS_PATH, GITHUB_URL } from "@constants/index";
-import { SLICES, STAGE_META } from "@lib/rubric";
+import { ASSESS_PATH, DOWNTIME_CALCULATOR_URL, GITHUB_URL } from "@constants/index";
+import { SLICES, STAGE_META, slicesOfKind } from "@lib/rubric";
import type { SliceColor, Stage } from "@lib/rubric/types";
import type { GetStaticProps } from "next";
/** Illustrative slice patterns for each stage row — not real results. */
const STAGE_EXAMPLES: Record = {
- 0: ["green", "green", "red", "green", "yellow", "green"],
- 1: ["green", "yellow", "green", "green", "yellow", "green"],
+ 0: ["green", "green", "green", "red", "green", "yellow"],
+ 1: ["green", "yellow", "yellow", "green", "green", "yellow"],
2: ["green", "green", "green", "green", "green", "green"],
};
@@ -42,8 +42,9 @@ export default function MethodologyPage() {
Methodology
- Validator Beat v0.1 is a self-assessment tool. You answer six banded
- questions; each answer maps directly to a slice color (green, yellow, or red). Your
+ Validator Beat is a self-assessment tool. You answer six banded
+ questions; each answer maps directly to a slice color (green, yellow, or red — or grey
+ for “not sure”). Your
overall Stage rolls up from those six colors. It's written for
operators assessing their own setup — and for stakers deciding whether an operator has
earned their stake. Nothing is sent to a server — scoring runs entirely in your browser,
@@ -64,7 +65,7 @@ export default function MethodologyPage() {
{" "}
one failure can slash you.{" "}
- (At least one slice is red — a single point of failure remains.)
+ (A safety slice is red or not sure.)
@@ -76,7 +77,7 @@ export default function MethodologyPage() {
{" "}
one failure won't slash you.{" "}
- (No red slices, but not all green yet.)
+ (Every safety slice is green or yellow; not all six are green yet.)
@@ -90,6 +91,20 @@ export default function MethodologyPage() {
(All six slices green.)
+
+
+ Safety slices and liveness slices
+
+
+ Each stage answers one question, so each slice gates the stage whose failure mode it
+ guards against. Safety slices —{" "}
+ {slicesOfKind("safety").map((s) => s.label).join(", ")} — decide whether one failure
+ can get you slashed or leak your keys, so a red or not-sure there holds you at Stage 0.{" "}
+ Liveness slices —{" "}
+ {slicesOfKind("liveness").map((s) => s.label).join(" and ")} — decide whether one
+ failure can take you offline, so they only stand between you and Stage 2. If one
+ provider could also reach your keys, that shows up under Key Custody, not here.
+
@@ -123,17 +138,38 @@ export default function MethodologyPage() {
How answers map to colors
- Each question offers three banded choices (best → worst). Your selection is{" "}
- the slice color — there is no separate backend calculation in v0.1.
+ Colors mean the same thing on every slice: red means one failure
+ could get you slashed, yellow means one failure could take you offline
+ (or a safety gap is only partly closed), and green means no single
+ failure can do either. So Key Custody and Client Diversity use all three bands; OS and
+ CPU are green or red, because the only question is whether one OS or architecture holds
+ enough key shares to sign; and Provider and Geography are green or yellow, because the
+ worst they can do is take you offline. Your selection is the slice color —
+ there is no separate backend calculation.
+
+
+ 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.
- Client diversity uses a simplified banded model; live network client
- share thresholds are deferred to a future operator registry (v1.2 spec).
+ 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.
+
+
+ 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.
Rule of thumb: if your setup falls between two answers, pick
- yellow. If the gray area hides a single point of failure, pick red. The{" "}
+ the worse one. If you can't tell, pick Not sure — your results will
+ say how to find out. The{" "}
nuances and limits section lists the known cases.
@@ -161,6 +197,16 @@ export default function MethodologyPage() {
defensive — it shrinks the cost of any single bad day: today for slashing, and under
EIP-7716, tomorrow for downtime.
+
+ The scale is what makes Stage 2 worth chasing. The{" "}
+
+ EIP-7716 downtime calculator
+ {" "}
+ shows penalties for an outage shared by 10% of validators rising to roughly 78× their
+ current rate, capped at 256× when a third of stake is offline. Validators that fail
+ alone would pay what they pay today — so the Provider and Geography slices, which
+ decide whether you fail alone or with a crowd, carry real money.
+
@@ -182,24 +228,24 @@ export default function MethodologyPage() {
Shared owner. Two key custodians inside one company fail together.
- Score Key Custody yellow.
+ Count them as one party when scoring Key Custody.
Derived distros. Ubuntu and Debian share upstream packaging. If your
- distros share a supply chain, score OS Diversity yellow.
+ distros share a supply chain, count them as one OS.
- One physical host. Two OSes in VMs on one machine share that
- machine. Score OS Diversity yellow.
+ One physical host. Two OSes in VMs on one machine are both reachable
+ through the host. Count the host's OS, not the guests'.
Resold infrastructure. Two providers reselling the same cloud or
- data centre fail together. Score Infrastructure yellow.
+ data centre fail together. Count them as one provider.
Active/passive failover. A warm standby still goes offline during
- failover. The four infrastructure slices assume active/active; score them no higher
- than yellow.
+ failover. The Provider and Geography slices assume active/active; score them
+ yellow.
- Your six-letter share code (e.g. GYRYGG) encodes green (G), yellow (Y), or
- red (R) per slice, in the slice order listed above. It lets you share a result link
- without storing personal data.
+ Your six-letter share code (e.g. GYYGGY) encodes green (G), yellow (Y),
+ red (R), or not sure (U) per slice, in the slice order listed above. It lets you share
+ a result link without storing personal data. Add ?vs= with another code
+ to share a head-to-head.
@@ -257,10 +304,10 @@ export default function MethodologyPage() {
target="_blank"
rel="noopener noreferrer"
>
- valOS — the Validator Operating Standard
+ ValOS — the Validator Operations Standard
{" "}— the canonical risk-and-mitigation catalog for validator operators. Nearly
- every risk surfaced in this assessment has a corresponding mitigation in valOS.
+ every risk surfaced in this assessment has a corresponding mitigation in ValOS.