diff --git a/components/assessment/AssessmentApp.tsx b/components/assessment/AssessmentApp.tsx index 830b724..4feb8e2 100644 --- a/components/assessment/AssessmentApp.tsx +++ b/components/assessment/AssessmentApp.tsx @@ -163,7 +163,6 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { - {share && a.stage != null && ( - Every slice green — maximum resilience. Nothing can slash you or take you offline. + Every slice green — maximum resilience. No single failure can slash you or take + you offline. ); } const target = stage === 0 ? "Stage 1" : "Stage 2"; return ( - {list.length} {list.length === 1 ? "win" : "wins"} from {target}:{" "} + {list.length} {list.length === 1 ? "win" : "wins"} away from {target}:{" "} {list.map((s) => s.short).join(", ")} ); @@ -43,7 +44,7 @@ export function Legend() { Green · no single point of failure - Yellow · partial + Yellow · partially mitigated Red · single point of failure @@ -68,5 +69,3 @@ export function pizzaOrigin(sliceIndex: number | null): { x: number; y: number } y: (r.top + r.height / 2) / window.innerHeight, }; } - -export { SLICES }; diff --git a/components/assessment/Results.tsx b/components/assessment/Results.tsx index 3db2523..51a3088 100644 --- a/components/assessment/Results.tsx +++ b/components/assessment/Results.tsx @@ -1,9 +1,9 @@ import React, { forwardRef, useEffect, useRef, useState } from "react"; import { Pizza } from "@components/pizza/Pizza"; import { VbButton } from "@components/ui/VbButton"; -import { SHARE_NAME_MAX, sharePreviewPath } from "@constants/index"; +import { SHARE_NAME_MAX } from "@constants/index"; import { downloadElementAsPng } from "@lib/share/download-image"; -import { SLICES, getTip, shareCode } from "@lib/rubric"; +import { SLICES, STAGE_META, getTip, shareCode } from "@lib/rubric"; import type { Answers, SliceColor, SliceId, Stage } from "@lib/rubric/types"; import type { SliceMeta } from "@lib/rubric/types"; import { @@ -60,53 +60,33 @@ import { risk, } from "./stitches"; -const STAGE = { - 0: { - name: "Stage 0", - kind: "Getting started", - tone: "red" as const, - line: "Every operator starts here. Clear the items in red below to reach Stage 1 — where no single failure can expose you to slashing.", - }, - 1: { - name: "Stage 1", - kind: "Safety", - tone: "yellow" as const, - line: "No single failure can expose you to slashing. One more climb to Stage 2 — where no single point of failure can take you offline or censor you either.", - }, - 2: { - name: "Stage 2", - kind: "Liveness", - tone: "green" as const, - line: "No single point of failure — your validator can't be slashed, stopped, or censored. You're upholding Ethereum's core values: decentralization, credible neutrality, and censorship resistance.", - }, -} as const; +/** Longer result-hero copy per stage; naming comes from STAGE_META. */ +const STAGE_LINE: Record = { + 0: "Every operator starts here. Clear the items in red below to reach Stage 1 — where no single failure can expose you to slashing.", + 1: "No single failure can expose you to slashing. One more climb to Stage 2 — where no single point of failure can take you offline either.", + 2: "No single point of failure — no single compromise can slash you, and no single outage can stop you. You're upholding Ethereum's core values: decentralization, credible neutrality, and censorship resistance.", +}; 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 bug can stop your validator.", - cpuDiversity: "No single CPU-architecture supply-chain risk.", - geoDiversity: "No single jurisdiction can stop or censor you.", -}; - -const SHARE_LINE: Record = { - 0: "Has a single point of failure — for now.", - 1: "Safe from slashing — no single failure can get it slashed.", - 2: "Maximum resilience — can't be slashed, stopped, or censored.", + osDiversity: "No single OS compromise can reach all of your keys.", + cpuDiversity: "No single CPU-level flaw can reach all of your keys.", + geoDiversity: "No single region's outage can take your validator offline.", }; type ResultHeroProps = { stage: Stage; answers: Answers; ownerName?: string }; export function ResultHero({ stage, answers, ownerName }: ResultHeroProps) { - const m = STAGE[stage]; + const m = STAGE_META[stage]; const tone = risk[m.tone]; const greens = SLICES.filter((s) => answers[s.id] === "green").length; const outOfRed = SLICES.filter((s) => answers[s.id] && answers[s.id] !== "red").length; const progress = stage === 2 - ? "All six dimensions maxed out" - : `${outOfRed} of 6 dimensions secured · ${greens} maxed`; + ? "All six slices maxed out" + : `${outOfRed} of 6 slices secured · ${greens} maxed`; return ( @@ -118,7 +98,7 @@ export function ResultHero({ stage, answers, ownerName }: ResultHeroProps) { {m.kind} {progress} - {m.line} + {STAGE_LINE[stage]} ); } @@ -202,7 +182,7 @@ type ShareCardProps = { export const ShareCard = forwardRef( function ShareCard({ answers, stage, shareUrl }, ref) { - const m = STAGE[stage]; + const m = STAGE_META[stage]; const tone = risk[m.tone]; return ( @@ -225,7 +205,7 @@ export const ShareCard = forwardRef( My validator setup is {m.name} {m.kind} - {SHARE_LINE[stage]} + {m.shareLine} How resilient is your validator? Find out → @@ -256,7 +236,6 @@ export function ShareModal({ const [nameError, setNameError] = useState(false); const cardRef = useRef(null); const code = shareCode(answers); - const previewPath = sharePreviewPath(code); const handleNameChange = (value: string) => { if (value.length > SHARE_NAME_MAX) { @@ -284,11 +263,11 @@ export function ShareModal({ const postToX = () => { const who = ownerName.trim(); const line = who - ? `${who}'s validator setup is ${STAGE[stage].name} on Validator Beat. How resilient is yours?` - : `My validator setup is ${STAGE[stage].name} on Validator Beat. How resilient is yours?`; + ? `${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://twitter.com/intent/tweet?text=${text}&url=${encodeURIComponent(shareUrl)}`, + `https://x.com/intent/post?text=${text}&url=${encodeURIComponent(shareUrl)}`, "_blank", "noopener,noreferrer", ); @@ -355,8 +334,8 @@ export function ShareModal({ - A preview image is generated per result at {previewPath} — anyone who - opens it sees your pizza, no data stored. + The link encodes your six colors ({code}) — anyone who opens it sees + this exact result. Nothing is stored. diff --git a/components/assessment/StageLadder.tsx b/components/assessment/StageLadder.tsx index de460d8..2f43eb8 100644 --- a/components/assessment/StageLadder.tsx +++ b/components/assessment/StageLadder.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { STAGE_META } from "@lib/rubric"; import type { Stage } from "@lib/rubric/types"; import { Ladder, @@ -12,29 +13,7 @@ import { LadderTag, } from "./stitches"; -const LADDER = [ - { - n: 0 as const, - name: "Stage 0", - kind: "Getting started", - tag: "Has a single point of failure", - tone: "red" as const, - }, - { - n: 1 as const, - name: "Stage 1", - kind: "Safety", - tag: "No single failure can get you slashed", - tone: "yellow" as const, - }, - { - n: 2 as const, - name: "Stage 2", - kind: "Liveness", - tag: "No single failure can stop or censor you", - tone: "green" as const, - }, -]; +const STAGE_ORDER: Stage[] = [0, 1, 2]; type StageLadderProps = { stage: Stage | null; @@ -44,29 +23,34 @@ type StageLadderProps = { export function StageLadder({ stage, vertical = false }: StageLadderProps) { return ( - {LADDER.map((s, i) => ( - - s.n} - vertical={vertical} - > - - {s.name} - {s.kind} - {stage === s.n && you're here} - {stage != null && stage > s.n && ( - - )} - - {s.tag} - - {i < 2 && ( - {vertical ? "↓" : "→"} - )} - - ))} + {STAGE_ORDER.map((n, i) => { + const meta = STAGE_META[n]; + return ( + + n} + vertical={vertical} + > + + {meta.name} + {meta.kind} + {stage === n && ( + you're here + )} + {stage != null && stage > n && ( + + )} + + {meta.tagline} + + {i < STAGE_ORDER.length - 1 && ( + {vertical ? "↓" : "→"} + )} + + ); + })} ); } diff --git a/components/landing/Landing.tsx b/components/landing/Landing.tsx index 8df85a1..f2717e0 100644 --- a/components/landing/Landing.tsx +++ b/components/landing/Landing.tsx @@ -18,7 +18,8 @@ import { METHODOLOGY_PATH as METHODOLOGY, VALOS_URL as VALOS, } from "@constants/index"; -import type { Answers, SliceId } from "@lib/rubric/types"; +import { SLICES, STAGE_META } from "@lib/rubric"; +import type { Answers, SliceId, Stage } from "@lib/rubric/types"; import type { CSS } from "@stitches/react"; import { IconArrowDown, @@ -302,6 +303,7 @@ const READ_SAMPLE: Answers = { geoDiversity: "yellow", }; +/** Landing-voice one-liners per slice; canonical labels come from SLICES. */ const SLICE_DESC: Record = { keyCustody: "How signing keys are held, and how many independent parties must cooperate to sign.", @@ -312,50 +314,14 @@ const SLICE_DESC: Record = { geoDiversity: "Concentration in one region and exposure to power failure or natural disaster.", }; -const READ_LABEL: Record = { - keyCustody: "Key Custody", - clientDiversity: "Client Diversity", - infraDiversity: "Provider Diversity", - osDiversity: "OS Diversity", - cpuDiversity: "CPU Architecture", - geoDiversity: "Geographic Diversity", +/** 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.", + 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.", }; -const ORDER: SliceId[] = [ - "keyCustody", - "clientDiversity", - "infraDiversity", - "geoDiversity", - "osDiversity", - "cpuDiversity", -]; - -const STAGES = [ - { - stage: "0" as const, - tone: "red" as const, - num: "0", - name: "Stage 0 — Exposed", - kind: "Starting point", - desc: "At least one single point of failure remains. Most operators start here — the baseline.", - }, - { - stage: "1" as const, - tone: "yellow" as const, - num: "1", - name: "Stage 1 — Slashing-averse", - kind: "Safety", - desc: "No single compromise of one machine, one team member, or one signer can produce a slashable message.", - }, - { - stage: "2" as const, - tone: "green" as const, - num: "2", - name: "Stage 2 — Downtime-averse", - kind: "Liveness", - desc: "No single point of failure in the operator's infrastructure can take the validator offline. This is the end game.", - }, -]; +const STAGE_ORDER: Stage[] = [0, 1, 2]; function LHero() { return ( @@ -375,7 +341,7 @@ function LHero() { . - When you stake ETH, you always (sometimes unknowingly) pick an operator. You{" "} + When you stake ETH, you pick an operator — sometimes without knowing it. You{" "} cannot see how they hold their keys, which clients they run, or how correlated their setup is with everyone else securing the network. Validator Beat makes that visible. @@ -391,10 +357,8 @@ function LHero() { - - - An example operator profile - + + An example operator profile @@ -507,24 +471,34 @@ function LRead() { The assessment How Validator Beat assesses an operator - Most operators start exposed with two stages to climb and a six-slice risk profile. + Six questions build a six-slice risk profile, and the slices roll up into a Stage. + Most operators start at Stage 0, with two stages to climb. - {STAGES.map(({ tone, num, name, kind, desc }) => ( - - - - {num} - - - {name} - {kind} + {STAGE_ORDER.map((n) => { + const { name, kind, tone } = STAGE_META[n]; + return ( + + + + {n} + + + + {name} — {kind} + + + {STAGE_META[n].tagline} + + - - {desc} - - ))} + + {STAGE_DESC[n]} + + + ); + })} @@ -537,11 +511,11 @@ function LRead() { - {ORDER.map((id) => ( + {SLICES.map(({ id, label }) => ( - {READ_LABEL[id]} + {label} {SLICE_DESC[id]} @@ -582,7 +556,7 @@ function LSides() { Read any operator's profile in five seconds. - Before you stake, not after an incident. Two stages and six colors give you insight into + Before you stake, not after an incident. A Stage and six colors give you insight into validator operations that the marketing page never will. @@ -615,14 +589,15 @@ function LValos() { The standard behind the score Validator Beat and valOS - Validator Beat is the public-facing who is running validators. 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 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. - A staker can quickly read an operator's stages and profile here. An operator doing + 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 - you'll end up at stage 2. + you'll end up at Stage 2. diff --git a/constants/index.ts b/constants/index.ts index 8d95feb..28c0a52 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -12,6 +12,5 @@ export const VALOS_URL = "https://lidofinance.github.io/valos/valos-spec.html"; export { getShareUrl, shareNameFromQuery, - sharePreviewPath, SHARE_NAME_MAX, } from "@lib/share/share-url"; diff --git a/lib/assessment/questions.ts b/lib/assessment/questions.ts index e325dea..b571d37 100644 --- a/lib/assessment/questions.ts +++ b/lib/assessment/questions.ts @@ -45,9 +45,9 @@ export const QUESTIONS: Record = { ], 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/" }, ], }, @@ -74,9 +74,9 @@ export const QUESTIONS: Record = { ], 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/" }, ], @@ -104,9 +104,9 @@ export const QUESTIONS: Record = { ], 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" }, ], }, @@ -128,9 +128,9 @@ export const QUESTIONS: Record = { ], 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: { @@ -151,8 +151,8 @@ export const QUESTIONS: Record = { ], 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: { @@ -178,10 +178,10 @@ export const QUESTIONS: Record = { ], 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" }, ], }, diff --git a/lib/rubric/index.ts b/lib/rubric/index.ts index 83fa9b4..ccd66de 100644 --- a/lib/rubric/index.ts +++ b/lib/rubric/index.ts @@ -1,8 +1,8 @@ -import type { Answers, SliceColor, SliceId, SliceMeta, Stage } from "./types"; +import type { Answers, SliceColor, SliceId, SliceMeta, Stage, StageMeta } from "./types"; import { COLORS } from "./types"; export { COLORS } from "./types"; -export type { Answers, SliceColor, SliceId, SliceMeta, Stage } from "./types"; +export type { Answers, SliceColor, SliceId, SliceMeta, Stage, StageMeta } from "./types"; export const RUBRIC_VERSION = "0.1"; @@ -11,17 +11,17 @@ export const SLICES: SliceMeta[] = [ id: "keyCustody", label: "Key Custody", short: "Keys", - why: "Concentrated private keys are a single point of failure — one compromise and an attacker can sign to slash your stake.", + 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", - why: "A supermajority-client bug can cause you to lose 100% of funds; refusing to attest during a chain split turns that into mere downtime.", + 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", + label: "Provider Diversity", short: "Provider", why: "One hosting provider's outage or compromise can take every validator hosted there with it.", }, @@ -29,27 +29,56 @@ export const SLICES: SliceMeta[] = [ id: "osDiversity", label: "OS Diversity", short: "OS", - why: "An OS monoculture is a supply-chain risk: one backdoor or zero-day could jeopardise a huge amount of validators.", + 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", - why: "A CPU-architecture monoculture is a hardware-level supply-chain and side-channel risk.", + 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", + label: "Geographic Diversity", short: "Geo", - why: "A validator fully in one country or region can be impacted by a natural or man-made disaster.", + why: "Nodes concentrated in one country or region share exposure to grid failures, natural disasters, and local policy shifts.", }, ]; +/** + * Canonical stage naming and taglines. Every surface that names a stage + * (results, ladder, landing, share cards, OG images) should read from here + * so the Stage 0/1/2 vocabulary stays identical everywhere. + */ +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.", + tone: COLORS.red, + }, + 1: { + name: "Stage 1", + kind: "Safety", + tagline: "No single failure can get you slashed", + shareLine: "Safe from slashing — no single failure can get it slashed.", + tone: COLORS.yellow, + }, + 2: { + name: "Stage 2", + kind: "Liveness", + tagline: "No single failure can take you offline", + shareLine: "Maximum resilience — no single failure can slash it or stop it.", + tone: COLORS.green, + }, +}; + export const TIPS: Record = { keyCustody: { - red: "Split the private keys so no single entity custodies it in full — use distributed key generation or split your backup mnemonic across 2+ parties, and run the validator private keys across multi-node setups (Dirk, Web3Signer, Distributed Validators).", + 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).", yellow: - "Distribute signing across 3+ independent parties (multi-operator DVT or distributed remote-signers), backups included, so no one party controls more than ⅓, and a failure of one won't introduce a liveness risk.", + "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.", }, clientDiversity: { red: "Run 3+ independent clients with a refuse-to-attest-on-disagreement setup (multi-operator DVT or a Vero/Vouch-style multiplexer).", @@ -57,23 +86,24 @@ export const TIPS: Record = { "Add at least one minority client so your combined client share stays under ⅔ of the network.", }, infraDiversity: { - red: "Move enough validators off your largest provider to get its share under ⅔.", + red: "Move enough nodes off your largest provider to bring its share under ⅔.", yellow: - "Spread hosting so no provider holds more than ⅓ of your active/active setup(add providers or self-host a portion).", + "Spread hosting so no provider backs more than ⅓ of your active/active nodes — add providers or self-host a portion.", }, osDiversity: { - red: "Run a second operating system distro across your multi-node validator (e.g. add Debian or NixOS next to Ubuntu) to ensure a compromised distro won't expose your private keys.", + 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: - "Run a third distinct distro so one distro's supply-chain compromise can't cause a liveness failure if enough partial keys have leaked to break your validator's consensus.", + "Add a third distinct distro so a single OS compromise can't threaten your validator's liveness either.", }, cpuDiversity: { - red: "Run your validator across ARM64 hardware (Apple Silicon, AWS Graviton, Ampere) and x86-64 machines.", + 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.", }, geoDiversity: { - red: "Run your validator across a second country/region to reduce your risk exposure.", - yellow: "Run your validator across several regions so no single country/region runs more than ⅓ of machines and can cause a liveness outage.", + 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.", }, }; diff --git a/lib/rubric/rubric.test.ts b/lib/rubric/rubric.test.ts index 8fc8baa..b5c4fa8 100644 --- a/lib/rubric/rubric.test.ts +++ b/lib/rubric/rubric.test.ts @@ -1,6 +1,7 @@ import { COLORS, SLICES, + STAGE_META, blockers, computeStage, decodeShareCode, @@ -56,4 +57,14 @@ describe("rubric v0.1", () => { const code = shareCode(allGreen()); expect(decodeShareCode(code)).toEqual(allGreen()); }); + + 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); + }); }); diff --git a/lib/rubric/types.ts b/lib/rubric/types.ts index 5a0ed53..5834dc3 100644 --- a/lib/rubric/types.ts +++ b/lib/rubric/types.ts @@ -24,3 +24,16 @@ export type SliceMeta = { short: string; why: string; }; + +export type StageMeta = { + /** Canonical display name, e.g. "Stage 1". */ + name: string; + /** One-word epithet shown as a chip next to the name. */ + kind: string; + /** One-line meaning of the stage. */ + tagline: string; + /** Punchy one-liner for share cards and OG preview images. */ + shareLine: string; + /** Risk color the stage maps to across the UI. */ + tone: SliceColor; +}; diff --git a/lib/share/og-meta.ts b/lib/share/og-meta.ts index fc6f0b4..822f181 100644 --- a/lib/share/og-meta.ts +++ b/lib/share/og-meta.ts @@ -1,13 +1,5 @@ -import { SLICES, computeStage } from "@lib/rubric"; -import type { Answers, Stage } from "@lib/rubric/types"; - -const STAGE_KIND: Record = { - 0: "Getting started", - 1: "Safety", - 2: "Liveness", -}; - -const COLOR_WORD = { green: "green", yellow: "yellow", red: "red" } as const; +import { SLICES, STAGE_META, computeStage } from "@lib/rubric"; +import type { Answers } from "@lib/rubric/types"; export function shareOgMeta(answers: Answers) { const stage = computeStage(answers); @@ -20,11 +12,11 @@ export function shareOgMeta(answers: Answers) { const sliceLine = SLICES.map((s) => { const c = answers[s.id]; - return c ? `${s.short}: ${COLOR_WORD[c]}` : `${s.short}: —`; + return c ? `${s.short}: ${c}` : `${s.short}: —`; }).join(" · "); return { - title: `Stage ${stage} · ${STAGE_KIND[stage]}`, + title: `${STAGE_META[stage].name} · ${STAGE_META[stage].kind}`, description: `${sliceLine}. Self-assessed validator security on ValidatorBeat.com`, }; } diff --git a/lib/share/pizza-og-svg.ts b/lib/share/pizza-og-svg.ts index 0a7e8c4..00e3296 100644 --- a/lib/share/pizza-og-svg.ts +++ b/lib/share/pizza-og-svg.ts @@ -1,4 +1,4 @@ -import { SLICES, computeStage } from "@lib/rubric"; +import { SLICES, STAGE_META, computeStage } from "@lib/rubric"; import type { Answers, Stage } from "@lib/rubric/types"; import { PIZZA_EMPTY, @@ -24,18 +24,6 @@ function wedgePath(cx: number, cy: number, r: number, a0: number, a1: number) { return `M${cx},${cy} L${x0.toFixed(1)},${y0.toFixed(1)} A${r},${r} 0 ${large} 1 ${x1.toFixed(1)},${y1.toFixed(1)} Z`; } -const STAGE_LINE: Record = { - 0: "Has a single point of failure — for now.", - 1: "No single failure risks slashing.", - 2: "Can't be slashed or stopped by a single failure.", -}; - -const STAGE_KIND: Record = { - 0: "Getting started", - 1: "Safety", - 2: "Liveness", -}; - const STAGE_COLOR: Record = { 0: PIZZA_FILL.red, 1: PIZZA_FILL.yellow, @@ -79,8 +67,8 @@ export function pizzaOgSvg( : ` MY VALIDATOR SETUP IS Stage ${stage} - ${STAGE_KIND[stage].toUpperCase()} - ${escapeXml(STAGE_LINE[stage])} + ${STAGE_META[stage].kind.toUpperCase()} + ${escapeXml(STAGE_META[stage].shareLine)} ${SLICES.map((s) => { const col = answers[s.id]; const dot = col ? PIZZA_FILL[col] : PIZZA_EMPTY_STROKE; diff --git a/lib/share/share-url.ts b/lib/share/share-url.ts index 5cafbd0..768f0fa 100644 --- a/lib/share/share-url.ts +++ b/lib/share/share-url.ts @@ -21,7 +21,3 @@ export function getShareUrl(code: string, name?: string): string { if (!trimmed) return path; return `${path}?n=${encodeURIComponent(trimmed)}`; } - -export function sharePreviewPath(code: string): string { - return getShareUrl(code).replace(/^https?:\/\//, ""); -} diff --git a/pages/[code].tsx b/pages/[code].tsx index dccb107..bc99701 100644 --- a/pages/[code].tsx +++ b/pages/[code].tsx @@ -38,7 +38,8 @@ export const getStaticProps: GetStaticProps = ({ params }) => { title: meta.title, description: meta.description, ogImage: `${base}/og/${code}.png`, - canonicalUrl: `${base}/${code}`, + // trailingSlash: true — the exported page lives at // + canonicalUrl: `${base}/${code}/`, }, }; }; diff --git a/pages/index.tsx b/pages/index.tsx index a6a4860..ad5ccb6 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -9,6 +9,6 @@ export const getStaticProps: GetStaticProps = () => ({ props: { title: "The standard for validator resilience", description: - "When you stake, you pick an operator — but you can't see how they run. Validator Beat makes validator resilience visible: two stages, six slices, one pizza.", + "When you stake, you pick an operator — but you can't see how they run. Validator Beat makes validator resilience visible: six slices, one Stage, one pizza.", }, }); diff --git a/pages/methodology.tsx b/pages/methodology.tsx index c89ccdd..df8b5ed 100644 --- a/pages/methodology.tsx +++ b/pages/methodology.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; import { SiteHeader } from "@components/layout/SiteHeader"; import { SiteFooter } from "@components/layout/SiteFooter"; -import { SLICES } from "@lib/rubric"; +import { ASSESS_PATH } from "@constants/index"; +import { SLICES, STAGE_META } from "@lib/rubric"; import type { GetStaticProps } from "next"; export default function MethodologyPage() { @@ -22,16 +23,24 @@ export default function MethodologyPage() {

Stages

  • - Stage 0 — Getting started: At least one slice is red (a single - point of failure remains). + + {STAGE_META[0].name} — {STAGE_META[0].kind}: + {" "} + At least one slice is red (a single point of failure remains).
  • - Stage 1 — Safety: No red slices, but not all green — no single - failure should be able to expose you to slashing. + + {STAGE_META[1].name} — {STAGE_META[1].kind}: + {" "} + No red slices, but not all green — no single failure should be able to expose you + to slashing.
  • - Stage 2 — Liveness: All six slices green — no single point of - failure should be able to slash you, stop you, or censor you. + + {STAGE_META[2].name} — {STAGE_META[2].kind}: + {" "} + All six slices green — no single point of failure should be able to slash you or + take you offline.
@@ -69,10 +78,10 @@ export default function MethodologyPage() { target="_blank" rel="noopener noreferrer" > - VALOS — Validator Operator Standards + valOS — the Validator Operating Standard - {" "}— the canonical risk-and-mitigation catalogue for validator operators. Nearly - every risk surfaced in this assessment has a corresponding mitigation in VALOS. + {" "}— the canonical risk-and-mitigation catalog for validator operators. Nearly + every risk surfaced in this assessment has a corresponding mitigation in valOS.
  • - + Take the assessment →