From bff6c47984885950b82174566175408b0a4b5f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 5 Jun 2026 17:16:47 +0100 Subject: [PATCH 1/3] First pass at validator beat --- CLAUDE.md | 87 +++++++++++ LICENSE | 202 ++++++++++++++++++++++++++ README.md | 4 + components/assessment/Intro.tsx | 11 +- components/assessment/Question.tsx | 29 ++++ components/assessment/Results.tsx | 10 +- components/assessment/StageLadder.tsx | 2 +- components/assessment/stitches.ts | 50 +++++-- lib/assessment/questions.ts | 120 +++++++++------ lib/rubric/index.ts | 30 ++-- package.json | 1 + pages/index.tsx | 2 +- pages/methodology.tsx | 53 ++++++- 13 files changed, 514 insertions(+), 87 deletions(-) create mode 100644 CLAUDE.md create mode 100644 LICENSE diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fce858d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 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. + +Authoritative product plan: [`docs/PLAN.md`](./docs/PLAN.md). Read it before changing the rubric, stages, or slice set. + +## Domain context: why these six slices + +The assessment exists because Ethereum validators have several distinct **single-points-of-failure (SPoF)** that operators routinely conflate or overlook. Obol's distributed-validator-technology (DVT) thesis is that an operator should be able to lose any one piece of their stack — a key custodian, a client, a host, an OS, a CPU vendor, a jurisdiction — without (a) leaking signing power [the "Safety" failure mode, Stage 1] or (b) going offline [the "Liveness" failure mode, Stage 2]. + +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) + +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. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser (no backend) │ +│ │ +│ questions.ts ──► AssessmentApp ──► Pizza (SVG) │ +│ (copy) (useAssessment ▲ │ +│ state machine) │ │ +│ ▲ │ │ +│ │ Results / Blockers / Tips │ +│ │ │ │ +│ └──── lib/rubric ────────────┘ │ +│ (pure functions: stage, blockers, shareCode) │ +└─────────────────────────────────────────────────────────────┘ +``` + +- **`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. +- **`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`. + +### Hard constraints from the static-export setup + +- `next.config.js` sets `output: "export"` — **no API routes, no `getServerSideProps`, no runtime image optimization** (`images.unoptimized: true`). All data must be derivable at build time or in the browser. +- `basePath` / `assetPrefix` come from `NEXT_PUBLIC_BASE_PATH`. GitHub Pages project deploys need `/validator-beat`; the custom domain (`validatorbeat.com`) needs it empty. Don't hardcode paths — use `next/link` and `NEXT_PUBLIC_SITE_URL` (via `constants/index.ts` → `SITE_URL`). +- `trailingSlash: true` — every internal link should match. +- React/ReactDOM are aliased to local copies in both `next.config.js` and `jest.config.ts` to prevent `@obolnetwork/obol-ui` (which bundles its own peer deps) from pulling in a second React. **Don't remove these aliases or the `resolutions` block in `package.json`** without testing. + +### Path aliases + +`@components/*`, `@constants/*`, `@hooks/*`, `@lib/*`, `@public/*`, `@styles/*` — defined in both `tsconfig.json` and `jest.config.ts`. Add new top-level dirs to both if needed. + +## Commands + +| Command | What it does | +|---|---| +| `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 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) | +| `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. +- 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`). + +## 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 `/`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index b5e958d..9d58c98 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,7 @@ Paste a live share link such as `https://validatorbeat.com/GYRYGG`. Debuggers ca - 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 + +## License + +Licensed under the Apache License, Version 2.0. See [LICENSE](./LICENSE). diff --git a/components/assessment/Intro.tsx b/components/assessment/Intro.tsx index 2141c88..e2ac0d6 100644 --- a/components/assessment/Intro.tsx +++ b/components/assessment/Intro.tsx @@ -5,7 +5,6 @@ import { IntroGoal, IntroGoalText, IntroLede, - IntroNote, IntroRoot, IntroTitle, StartButtonWrap, @@ -22,8 +21,9 @@ export function Intro({ onStart }: IntroProps) { A neutral resource for validator fault tolerance How resilient is your validator setup? - Six quick questions about how you run. Each one checks for a single point of failure — one - party, client, provider, or place that, if it broke or turned hostile, could get you + Six quick questions about how you run. Each one checks for a single point of failure — + any party, client, provider, machine, or place that, if it broke or turned hostile, could + get you slashed, knocked offline, or censored. Your answers fill the @@ -37,11 +37,6 @@ export function Intro({ onStart }: IntroProps) { - - Assumes each validator runs as a distributed validator (DVT) or behind a{" "} - multiplexer — its duties split across several nodes, so if one node's provider, - OS, or region fails, the rest keep signing. - Start the assessment → diff --git a/components/assessment/Question.tsx b/components/assessment/Question.tsx index 17c9220..b0b298f 100644 --- a/components/assessment/Question.tsx +++ b/components/assessment/Question.tsx @@ -12,6 +12,11 @@ import { OptionSub, QuestionCount, QuestionHelper, + QuestionRefLink, + QuestionRefs, + QuestionRisk, + QuestionRiskBody, + QuestionRiskHead, QuestionRoot, QuestionSlice, QuestionTitle, @@ -74,6 +79,30 @@ export function Question({ ); })} + {(data.risk || (data.references && data.references.length > 0)) && ( + + {data.risk && ( + <> + Why this matters + {data.risk} + + )} + {data.references && data.references.length > 0 && ( + + {data.references.map((r) => ( + + {r.label} ↗ + + ))} + + )} + + )} {index > 0 && onBack && ( ← Back diff --git a/components/assessment/Results.tsx b/components/assessment/Results.tsx index c5dc2b7..3db2523 100644 --- a/components/assessment/Results.tsx +++ b/components/assessment/Results.tsx @@ -65,13 +65,13 @@ const STAGE = { 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 party can get your validator slashed.", + 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 party can get you slashed. One more climb to Stage 2 — where no single failure can take you offline or censor you.", + 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", @@ -82,8 +82,8 @@ const STAGE = { } as const; const WHY_MAXED: Record = { - keyCustody: "No single party can sign with your stake.", - clientDiversity: "No supermajority-client bug can slash you.", + 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.", @@ -92,7 +92,7 @@ const WHY_MAXED: Record = { const SHARE_LINE: Record = { 0: "Has a single point of failure — for now.", - 1: "Safe from slashing — no single party can get it slashed.", + 1: "Safe from slashing — no single failure can get it slashed.", 2: "Maximum resilience — can't be slashed, stopped, or censored.", }; diff --git a/components/assessment/StageLadder.tsx b/components/assessment/StageLadder.tsx index 6d743af..de460d8 100644 --- a/components/assessment/StageLadder.tsx +++ b/components/assessment/StageLadder.tsx @@ -24,7 +24,7 @@ const LADDER = [ n: 1 as const, name: "Stage 1", kind: "Safety", - tag: "No single party can slash you", + tag: "No single failure can get you slashed", tone: "yellow" as const, }, { diff --git a/components/assessment/stitches.ts b/components/assessment/stitches.ts index 825a776..a16b2ba 100644 --- a/components/assessment/stitches.ts +++ b/components/assessment/stitches.ts @@ -237,16 +237,6 @@ export const IntroGoalText = styled(Text, { "& b": { color: "$body" }, }); -export const IntroNote = styled(Text, { - fontSize: "$2", - lineHeight: 1.5, - color: "$textMiddle", - marginTop: "$xs", - paddingLeft: "$xs", - borderLeft: "2px solid $bg05", - "& b": { color: "$textLight" }, -}); - export const StartButtonWrap = styled(Box, { alignSelf: "flex-start", marginTop: "$md", @@ -512,6 +502,46 @@ export const OptionCheck = styled(Text, { }, }); +export const QuestionRisk = styled(Box, { + marginTop: "$md", + padding: "12px 14px", + backgroundColor: "$bg02", + border: "1px solid $bg05", + borderLeft: "3px solid $bg07", + borderRadius: "$3", +}); + +export const QuestionRiskHead = styled(Text, { + fontSize: "$1", + textTransform: "uppercase", + letterSpacing: "0.08em", + color: "$textMiddle", + fontWeight: 600, + marginBottom: 6, +}); + +export const QuestionRiskBody = styled(Text, { + fontSize: "$2", + lineHeight: 1.55, + color: "$textLight", +}); + +export const QuestionRefs = styled(Box, { + marginTop: 10, + display: "flex", + flexWrap: "wrap", + gap: "$xs", +}); + +export const QuestionRefLink = styled("a", { + fontSize: "$1", + color: "$textMiddle", + textDecoration: "underline", + textDecorationStyle: "dotted", + textUnderlineOffset: "2px", + "&:hover": { color: "$textLight" }, +}); + export const BackButton = styled("button", { all: "unset", cursor: "pointer", diff --git a/lib/assessment/questions.ts b/lib/assessment/questions.ts index feeff00..ce9c109 100644 --- a/lib/assessment/questions.ts +++ b/lib/assessment/questions.ts @@ -6,61 +6,85 @@ export type QuestionOption = { sub: string; }; +export type QuestionReference = { + label: string; + url: string; +}; + export type Question = { q: string; helper: string; options: QuestionOption[]; + /** Anonymized real-world incident illustrating the risk this slice mitigates. */ + risk?: string; + /** Useful external resources — tools, dashboards, security features. */ + references?: QuestionReference[]; }; export const QUESTIONS: Record = { keyCustody: { - q: "What's the largest share of your signing power that any one party controls — counting key backups the same as live keys?", + q: "What's the largest share of your signing power that any single point of failure could control or compromise — counting key backups the same as live keys?", helper: - "A “party” is one machine, team, or person. With threshold signing, a party needs more than ⅔ of the key shares to sign on its own.", + "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.", options: [ { color: "green", label: "No more than ⅓", - sub: "Signing needs at least 3 independent parties to agree — no single compromise can sign.", + sub: "Signing needs at least 3 independent components to converge — no single compromise can sign.", }, { color: "yellow", label: "Between ⅓ and ⅔", - sub: "One party holds a meaningful share, but still can't sign on its own.", + sub: "One failure holds a meaningful share, but still can't sign on its own.", }, { color: "red", label: "More than ⅔", - sub: "One party can sign on your behalf, by itself.", + sub: "A single failure can 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: "Obol docs — distributed validators", url: "https://docs.obol.org/" }, + ], }, clientDiversity: { - q: "How protected are you from a buggy client signing the wrong chain and getting you slashed?", + q: "If a consensus client developed a critical bug and forked onto an incorrect chain, what would stop your validator from signing along with it?", helper: - "“Refuse-to-attest” means your setup won't sign when its clients disagree, so a single client bug becomes downtime, not a slashing — via multi-operator DVT, a multiplexer like Vero, or a similar setup.", + "Two independent safeguards. (1) Halt-on-disagreement configured in your stack: Charon (DVT) halts when more than its BFT threshold of operators disagree, i.e. when QBFT can't reach a decision; Vero exposes --attestation-consensus-threshold (default majority, set equal to the beacon-node count for unanimity); Vouch uses strategies.attestationdata.style: majority with a tunable majority.threshold (set to N for unanimity). (2) Keep your full client mix under ⅔ of the network. If every client you run is on the supermajority side, they all fork the same way, the halt-on-disagreement safeguard never triggers, and you get slashed alongside the network. See clientdiversity.org for current client shares.", options: [ { color: "green", - label: "3+ independent clients, refuse-to-attest, plus a minority client", - sub: "Your combined client share stays under ⅔ — a supermajority-client bug can't slash you.", + label: "Refuse-to-attest configured, 3+ independent clients, includes a minority client", + sub: "Your combined client share stays under ⅔ of the network — no supermajority-client fork can sweep you up.", }, { color: "yellow", - label: "3+ independent clients that refuse to attest when they disagree", - sub: "Safe from a single-client bug, but you're not adding minority-client share to the network.", + label: "Refuse-to-attest configured, 3+ independent clients, all on supermajority-share clients", + sub: "Safe from a single-client bug, but if every client you run is in the bad supermajority, they fork in unison and the safeguard never fires.", }, { color: "red", - label: "A single client, or no refuse-to-attest safeguard", - sub: "A supermajority-client bug could drag you into signing the wrong chain.", + label: "Single client, or no refuse-to-attest safeguard", + sub: "A consensus-client bug could drag you into signing the wrong chain — 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. 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: "clientdiversity.org", url: "https://clientdiversity.org/" }, + { label: "Obol docs — chain_split_halt", url: "https://docs.obol.org/" }, + ], }, infraDiversity: { q: "Across the nodes that run your validators, what's the largest share on a single hosting provider?", helper: - "Diversity only buys uptime if your validator keeps signing when some nodes drop — i.e. it runs as a distributed validator (DVT) or behind a multiplexer. Count each cloud (AWS, Hetzner…) and “home / bare-metal” as its own provider.", + "Think active/active: in a multi-operator DVT, or a multiplexer like Vero fronting several beacon nodes, multiple machines cooperate to back the same stake. “Share on a single provider” here is the share of those cooperating machines, not a share of stake — diversifying the machine inventory means a single provider's outage drops only some of them, and the validator keeps signing. Count each cloud (AWS, Hetzner…) and “home / bare-metal” as its own provider.", options: [ { color: "green", @@ -78,71 +102,85 @@ export const QUESTIONS: Record = { sub: "One provider going down takes 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" }, + ], }, osDiversity: { - q: "How many distinct operating systems run across those nodes?", + q: "Could a compromise of a single operating system expose all of your signing material?", helper: - "Distinct Linux/Unix distributions — Ubuntu, Debian, Arch, NixOS, macOS, etc.", + "Distinct Linux/Unix distributions count separately — Ubuntu, Debian, Arch, NixOS, macOS, etc. The concern is compromise (an OS-level RCE, a poisoned package update, a build-pipeline attack) reaching every key at once, not uptime. Mixing distros forces an attacker to break multiple independent supply chains to reach all of your signing material.", options: [ { color: "green", - label: "3 or more", - sub: "No single OS bug or bad update can stop your validator. e.g. Ubuntu + Arch + NixOS.", - }, - { - color: "yellow", - label: "2 distinct distros", - sub: "e.g. Ubuntu + macOS.", + 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.", }, { color: "red", - label: "1 — everything on one OS", - sub: "e.g. 100% Ubuntu LTS.", + label: "Yes — all signing material runs on a single OS", + sub: "One OS-level vulnerability or poisoned update could reach every key you hold.", }, ], + 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" }, + ], }, cpuDiversity: { - q: "How many distinct CPU architectures run across those nodes?", + q: "Could a compromise of a single CPU architecture expose all of your signing material?", helper: - "The instruction set the chip speaks — x86-64, ARM64, RISC-V — not the chip model. RISC-V is barely available today, so a third architecture is frontier for almost everyone.", + "The instruction set the chip speaks — x86-64, ARM64, RISC-V — not the chip model. Side-channel and speculative-execution disclosures (Spectre, Meltdown, and successors) are architecture-bound, so splitting your signing material across two ISAs (typically x86-64 + ARM64) limits the blast radius of any single hardware-class disclosure. RISC-V is barely available today, so two is the practical ceiling for most operators.", options: [ { color: "green", - label: "3 or more architectures", - sub: "x86-64 + ARM64 + RISC-V — aspirational today.", - }, - { - color: "yellow", - label: "2 architectures", - sub: "typically x86-64 + ARM64.", + label: "No — signing material is split across two or more architectures", + sub: "Typically x86-64 + ARM64 (Apple Silicon, AWS Graviton, Ampere) — an architecture-bound side-channel can only reach the keys on that ISA.", }, { color: "red", - label: "1 architecture", - sub: "typically 100% x86-64.", + label: "Yes — all signing material runs on a single architecture", + sub: "Typically 100% x86-64 — one CPU-class disclosure could reach every key.", }, ], + 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" }, + ], }, geoDiversity: { - q: "What's the largest share of those nodes in a single country or legal jurisdiction?", + q: "What's the largest share of those nodes in a single country or region?", helper: - "Where the nodes physically run — grouped by country or jurisdiction, not data-centre.", + "Where the cooperating nodes that back your stake physically run — grouped by country or region, not data-centre. In an active/active setup, a country-level outage (grid blackout, weather event, network disruption) only drops the nodes in that region; the rest keep signing.", options: [ { color: "green", label: "Less than 33%", - sub: "No single jurisdiction can take your validator offline or compel you to censor.", + sub: "No issue in a single region can put your validator offline.", }, { color: "yellow", label: "33% to 66%", - sub: "A large share of your nodes sit in one jurisdiction.", + sub: "A situation in this country or region could take your validator offline.", }, { color: "red", label: "More than 66%", - sub: "One ruling or regional outage could take your validator offline.", + sub: "An issue in this region could risk your validator's security.", }, ], + 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" }, + ], }, }; diff --git a/lib/rubric/index.ts b/lib/rubric/index.ts index afb1101..806a924 100644 --- a/lib/rubric/index.ts +++ b/lib/rubric/index.ts @@ -11,25 +11,25 @@ export const SLICES: SliceMeta[] = [ id: "keyCustody", label: "Key Custody", short: "Keys", - why: "Concentrated signing material is a single point of failure — one compromise can sign with your stake.", + why: "Concentrated private keys are a single point of failure — one compromise and an attacker can sign to slash your stake.", }, { id: "clientDiversity", label: "Client Diversity", short: "Clients", - why: "A supermajority-client bug can slash a single-client setup; refusing to attest on disagreement turns that into mere downtime.", + 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.", }, { id: "infraDiversity", label: "Infrastructure", short: "Infra", - why: "One hosting provider's outage or compromise takes every validator hosted there with it.", + why: "One hosting provider's outage or compromise can take every validator hosted there with it.", }, { id: "osDiversity", label: "OS Diversity", short: "OS", - why: "An OS monoculture is a supply-chain risk: one poisoned build pipeline can backdoor the whole fleet.", + why: "An OS monoculture is a supply-chain risk: one backdoor or zero-day could jeopardise a huge amount of validators.", }, { id: "cpuDiversity", @@ -41,39 +41,39 @@ export const SLICES: SliceMeta[] = [ id: "geoDiversity", label: "Geographic", short: "Geo", - why: "All validators in one jurisdiction can be hit by a single sovereign action, outage, or regulation.", + why: "A validator fully in one country or region can be impacted by a natural or man-made disaster.", }, ]; export const TIPS: Record = { keyCustody: { - red: "Split signing so no single party controls more than ⅔ — move to threshold signing and split your backup mnemonic across 2+ parties.", + 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).", yellow: - "Distribute signing across 3+ independent parties (multi-operator DVT or distributed MPC), backups included, so no one party controls more than ⅓.", + "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.", }, clientDiversity: { - red: "Run 3+ independent clients with a refuse-to-attest-on-disagreement setup (multi-operator DVT or a Vero-style multiplexer).", + 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.", }, infraDiversity: { red: "Move enough validators off your largest provider to get its share under ⅔.", yellow: - "Spread hosting so no provider holds more than ⅓ (add providers or self-host a portion).", + "Spread hosting so no provider holds more than ⅓ of your active/active setup(add providers or self-host a portion).", }, osDiversity: { - red: "Run a second OS distro across part of your fleet (e.g. add Debian or NixOS next to Ubuntu).", + 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.", yellow: - "Run a third distinct distro so one distro's supply-chain compromise can't hit the whole fleet.", + "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.", }, cpuDiversity: { - red: "Add ARM64 hardware (Apple Silicon, AWS Graviton, Ampere) next to your x86-64 nodes.", + red: "Run your validator across ARM64 hardware (Apple Silicon, AWS Graviton, Ampere) and x86-64 machines.", yellow: - "Add a third ISA (RISC-V) once viable — aspirational for nearly all operators today.", + "Add a third ISA (RISC-V) once viable — aspirational for nearly all setups today.", }, geoDiversity: { - red: "Move enough validators to a second country/region so no single one holds more than ⅔.", - yellow: "Spread across regions so no single country/region holds more than ⅓.", + 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.", }, }; diff --git a/package.json b/package.json index 135d9ef..6a98a84 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "validator-beat", "version": "0.1.0", "private": true, + "license": "Apache-2.0", "engines": { "node": ">=20.0.0" }, diff --git a/pages/index.tsx b/pages/index.tsx index 3b43a3f..a151268 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -9,6 +9,6 @@ export const getStaticProps: GetStaticProps = () => ({ props: { title: "Self-assessment", description: - "Six questions about your validator setup. Score your resilience in about a minute — nothing is submitted or stored.", + "Six questions about your Ethereum validator setup. Score your resilience in about a minute — nothing is submitted or stored.", }, }); diff --git a/pages/methodology.tsx b/pages/methodology.tsx index 41acf45..f4ee3e4 100644 --- a/pages/methodology.tsx +++ b/pages/methodology.tsx @@ -38,11 +38,11 @@ export default function MethodologyPage() {
  • Stage 1 — Safety: No red slices, but not all green — no single - party should be able to get you slashed. + failure should be able to expose you to slashing.
  • - Stage 2 — Liveness: All six slices green — no single failure should - be able to slash you, stop you, or censor you. + Stage 2 — Liveness: All six slices green — no single point of + failure should be able to slash you, stop you, or censor you.
  • @@ -57,12 +57,53 @@ export default function MethodologyPage() { ))}

    - For infrastructure, OS, CPU, and geography, questions assume each validator runs as a{" "} - distributed validator (DVT) or behind a multiplexer — - duties split across independent nodes so diversity translates into uptime. + For infrastructure, OS, CPU, and geography, diversity only translates into resilience + when your validator runs active/active: several cooperating nodes back + the same stake, with signing continuing as long as enough of them stay up. The stake + isn't partitioned across machines — it's one aggregate validator whose + cooperating machines you've diversified. Several setups achieve this — + multi-operator distributed validators (DVT) coordinated by Charon, or + validator clients like Vouch paired with multiplexers like{" "} + Vero and remote signers like Dirk or{" "} + Web3Signer. The common requirement: no single party holds enough key + material to sign alone — at least two independent parties involved, backups kept + separate, so compromising one doesn't leak the full private key.

    +
    +

    Further reading

    +
      +
    • + + VALOS — Validator Operator Standards + + {" "}— the canonical risk-and-mitigation catalogue for validator operators. Nearly + every risk surfaced in this assessment has a corresponding mitigation in VALOS. +
    • +
    • + + clientdiversity.org + + {" "}— live network share for each Ethereum consensus and execution client. +
    • +
    • + + Obol docs + + {" "}— distributed validators, Charon, and chain-split safety settings. +
    • +
    +
    +

    How answers map to colors

    From d7b0a34605048dbc20e71beba65ca3e92e39e821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 5 Jun 2026 18:04:20 +0100 Subject: [PATCH 2/3] Finish Pass --- lib/assessment/questions.ts | 4 +++- lib/share/og-meta.ts | 6 +++--- lib/share/pizza-og-svg.ts | 20 ++++++++++++++------ lib/theme/tokens.ts | 8 ++++---- package.json | 1 + pages/_document.tsx | 8 ++++++++ pages/methodology.tsx | 33 +++++++++++++++++++++++++++++++++ public/apple-touch-icon.png | Bin 0 -> 5422 bytes public/favicon-16.png | Bin 0 -> 472 bytes public/favicon-32.png | Bin 0 -> 953 bytes public/icon-192.png | Bin 0 -> 5758 bytes public/icon-512.png | Bin 0 -> 17621 bytes public/icon.svg | 22 ++++++++++++++++++++++ public/site.webmanifest | 12 ++++++++++++ scripts/generate-icons.ts | 29 +++++++++++++++++++++++++++++ scripts/generate-og-images.ts | 8 +++++++- styles/theme-tokens.css | 22 +++++++++++++--------- 17 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon-16.png create mode 100644 public/favicon-32.png create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/icon.svg create mode 100644 public/site.webmanifest create mode 100644 scripts/generate-icons.ts diff --git a/lib/assessment/questions.ts b/lib/assessment/questions.ts index ce9c109..a690024 100644 --- a/lib/assessment/questions.ts +++ b/lib/assessment/questions.ts @@ -72,7 +72,7 @@ export const QUESTIONS: Record = { sub: "A consensus-client bug could drag you into signing the wrong chain — 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. 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.", + 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" }, @@ -107,6 +107,7 @@ export const QUESTIONS: Record = { { 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" }, ], }, osDiversity: { @@ -181,6 +182,7 @@ export const QUESTIONS: Record = { { 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/share/og-meta.ts b/lib/share/og-meta.ts index 0fc362b..fc6f0b4 100644 --- a/lib/share/og-meta.ts +++ b/lib/share/og-meta.ts @@ -13,7 +13,7 @@ export function shareOgMeta(answers: Answers) { const stage = computeStage(answers); if (stage == null) { return { - title: "Validator result", + title: "Validator Security Result", description: SITE_FALLBACK_DESC, }; } @@ -25,9 +25,9 @@ export function shareOgMeta(answers: Answers) { return { title: `Stage ${stage} · ${STAGE_KIND[stage]}`, - description: `${sliceLine}. Self-assessed validator resilience on Validator Beat.`, + description: `${sliceLine}. Self-assessed validator security on ValidatorBeat.com`, }; } const SITE_FALLBACK_DESC = - "Six-slice validator resilience self-assessment — nothing stored."; + "Is your Ethereum validator secure and resilient? Take this self-assessment to find out."; diff --git a/lib/share/pizza-og-svg.ts b/lib/share/pizza-og-svg.ts index ee6af13..0a7e8c4 100644 --- a/lib/share/pizza-og-svg.ts +++ b/lib/share/pizza-og-svg.ts @@ -26,8 +26,8 @@ function wedgePath(cx: number, cy: number, r: number, a0: number, a1: number) { const STAGE_LINE: Record = { 0: "Has a single point of failure — for now.", - 1: "Safe from slashing — no single party can get it slashed.", - 2: "Maximum resilience — can't be slashed, stopped, or censored.", + 1: "No single failure risks slashing.", + 2: "Can't be slashed or stopped by a single failure.", }; const STAGE_KIND: Record = { @@ -46,7 +46,11 @@ function escapeXml(s: string) { return s.replace(/&/g, "&").replace(//g, ">"); } -export function pizzaOgSvg(answers: Answers, code: string): string { +export function pizzaOgSvg( + answers: Answers, + code: string, + displayHost: string = "validatorbeat.com", +): string { const stage = computeStage(answers); const cx = 300; const cy = 315; @@ -77,7 +81,11 @@ export function pizzaOgSvg(answers: Answers, code: string): string { Stage ${stage} ${STAGE_KIND[stage].toUpperCase()} ${escapeXml(STAGE_LINE[stage])} - ${SLICES.map((s) => `${s.short} ${answers[s.id] ? answers[s.id]![0].toUpperCase() : "—"}`).join(" · ")}`; + ${SLICES.map((s) => { + const col = answers[s.id]; + const dot = col ? PIZZA_FILL[col] : PIZZA_EMPTY_STROKE; + return `${escapeXml(s.short)} `; + }).join("· ")}`; const hub = stage != null @@ -91,11 +99,11 @@ export function pizzaOgSvg(answers: Answers, code: string): string { Validator Beat - ${code} + ${escapeXml(displayHost)}/${code} ${wedges} ${hub} ${stageBlock} - How resilient is your validator? Find out → + How resilient is your validator? Find out at ValidatorBeat.com→ `; } diff --git a/lib/theme/tokens.ts b/lib/theme/tokens.ts index 082d34f..964c88b 100644 --- a/lib/theme/tokens.ts +++ b/lib/theme/tokens.ts @@ -4,16 +4,16 @@ */ import type { SliceColor } from "@lib/rubric/types"; -export const THEME_BRAND = "#00a3ff"; +export const THEME_BRAND = "#16968e"; export const PIZZA_FILL: Record = { - green: "#3d854d", + green: "#1f9b6b", yellow: "#e7c300", red: "#bb1122", }; export const PIZZA_RING: Record = { - green: "#8fd4a0", + green: "#82edcc", yellow: "#ffe566", red: "#e85c6f", }; @@ -25,7 +25,7 @@ export const PIZZA_INK = "#000000"; export const CONFETTI_BRAND = [ THEME_BRAND, - "#3369ff", + "#2fe4ab", PIZZA_FILL.green, PIZZA_FILL.yellow, "#ffffff", diff --git a/package.json b/package.json index 6a98a84..696331e 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "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": "yarn build:og && next build", "start": "next start", "lint": "next lint", diff --git a/pages/_document.tsx b/pages/_document.tsx index 826559b..8fd08be 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -9,11 +9,19 @@ const Body = styled("body", { color: "var(--fg-1)", }); +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + export default function Document() { return (