diff --git a/CLAUDE.md b/CLAUDE.md index c1a62b5..c0893fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -791,6 +791,33 @@ third passed it to `title` as a hover. Both are wanted, so the shared one takes them as separate props rather than picking a winner and silently changing a page. Same lesson as `isStandings`, found the same way: by going to add a fourth. +## The donation QR is generated, never shipped as an image + +`AboutPanel` builds the code in the browser from `DONATION_ADDRESS`, the same +constant it prints underneath. A supplied PNG beside a typed address is **two +independent claims about where money goes**, and nothing on the page could tell +a reader if they ever stopped agreeing — the failure is unrecoverable. Generated, +there is one address and the code is a rendering of it. + +Verified rather than assumed: the matrix `qrcode-generator` produces was decoded +from first principles — function-pattern map, mask 3 unapplied, codewords +de-interleaved across version 4-M's two blocks — and reads back +`qzkQq9ybiPm7gQR9E1ViQbW9RhqA2Rex9ZQkScU5zdJqjrre4` exactly. Worth knowing if +this is ever re-checked: an independent encoder (`segno`) produces a *different* +matrix for the same string at every mask, because mode segmentation differs. +That is not a disagreement about the payload and comparing matrices proves +nothing; only a decode does. + +`the_donation_address_on_the_site_is_a_valid_quantus_address` reads the literal +**out of the frontend source** and puts it through the SS58 decoder, so a typo +fails CI. It is not duplicated into the test, because a copy is one more place +for the two to disagree. Confirmed to fail on a single changed character. + +The QR draws its own white background and black modules rather than using the +palette. A code in the accent hue on warm-black does not scan, whatever the +contrast validator says about text — scanners want dark on light, and this is +the one mark on the site whose job is to be read by a machine. + ## Charts Any chart added here must be read against the `dataviz` skill first. The diff --git a/crates/blackbeard-core/src/wormhole.rs b/crates/blackbeard-core/src/wormhole.rs index c1c0f7e..aa259b7 100644 --- a/crates/blackbeard-core/src/wormhole.rs +++ b/crates/blackbeard-core/src/wormhole.rs @@ -189,6 +189,34 @@ pub fn abbreviated_address(address: &str) -> String { #[cfg(test)] mod tests { + /// The donation address the site prints must be a real Quantus address. + /// + /// Read out of the frontend source rather than duplicated here, because a + /// copy would be one more place for the two to disagree and the whole point + /// is that there is exactly one address. A typo in it is unrecoverable — + /// somebody sends money to a string nobody holds a key for — and neither + /// `tsc` nor a linter has any opinion about base58, so this is the only + /// thing standing between a slip and a loss. + #[test] + fn the_donation_address_on_the_site_is_a_valid_quantus_address() { + let source = include_str!("../../../web/src/components/AboutPanel.tsx"); + let line = source + .lines() + .find(|l| l.contains("DONATION_ADDRESS")) + .expect("AboutPanel declares DONATION_ADDRESS"); + let address = line + .split('\'') + .nth(1) + .expect("the constant is a single-quoted string literal"); + + let account = super::account_id(address).unwrap_or_else(|| { + panic!("`{address}` is not a valid Quantus address — checksum or network prefix") + }); + // And it survives the round trip, so the printed form is canonical + // rather than merely decodable. + assert_eq!(super::ss58_of(&account).as_deref(), Some(address)); + } + use super::*; /// Pinned against the real pair: `baba-gorchitsa` is the observer's own diff --git a/web/package.json b/web/package.json index eb0cd8c..d5eefae 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "qrcode-generator": "^2.0.4", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.8.0" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f50b307..6847261 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + qrcode-generator: + specifier: ^2.0.4 + version: 2.0.4 react: specifier: ^19.1.1 version: 19.2.8 @@ -913,6 +916,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode-generator@2.0.4: + resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==} + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -1769,6 +1775,8 @@ snapshots: punycode@2.3.1: {} + qrcode-generator@2.0.4: {} + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 diff --git a/web/src/App.tsx b/web/src/App.tsx index 85c54d2..dd19f80 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import { SectionNav } from './components/SectionNav' import { ThemeToggle } from './components/ThemeToggle' import { StateIndex } from './components/StateIndex' import { StatBar } from './components/StatBar' +import { AboutPanel } from './components/AboutPanel' import { VestingPanel } from './components/VestingPanel' import { WormholeStage } from './components/WormholeStage' import { measuredSpan, seconds, windowSpan } from './lib/format' @@ -154,6 +155,18 @@ export default function App() { )} + {/* Before the connection light and the toggle, as the last thing in + the header that is about the site rather than about the chain. */} + {chain && ( + + About + + )} +
{state.connection} @@ -285,6 +298,7 @@ export default function App() { )} {route.index === 'node' && chain && } + {route.index === 'about' && } {route.index === 'vesting' && chain && ( + {/* The light modules are drawn, not left transparent: a QR on a dark + surface with a transparent background does not scan, and this site has + a dark theme. */} + + + + ) +} + +export function AboutPanel() { + return ( + <> +
+
+

About

+ who made this +
+
+

+ blackbeard.observer is a mining leaderboard and block explorer for the + Quantus network. It is built and its source provided freely by a member of the Quantus + user community — not by the Quantus project, and with no standing behind it beyond the + code being open and the numbers being checkable. +

+

+ The same author maintains a{' '} + + CUDA performance-optimised miner + + , also free and open source. Between them they are the reason a good deal of this site + exists: a miner needs somewhere to see whether it is winning. +

+

+ Everything the observer shows is derived from public block headers, chain state and the + network's own telemetry feed. The code that derives it is at{' '} + + git.lair.cafe/quantus/blackbeard.observer + + , so any figure here can be traced to the read that produced it. +

+
+
+ +
+
+

Donations

+ entirely optional +
+
+ +
+

+ If the observer or the miner have been useful and you are inclined, donations toward + development, maintenance and hosting are gratefully accepted in QTC at: +

+

+ {DONATION_ADDRESS} +

+

+ Nothing here is gated behind it, and nothing about the site changes if you do. The + code above is generated in your browser from the address printed beside it, so the two + cannot drift apart — scan it or copy the text, whichever you trust more. +

+
+
+
+ + ) +} diff --git a/web/src/components/SectionNav.tsx b/web/src/components/SectionNav.tsx index bdd0ae0..cc33199 100644 --- a/web/src/components/SectionNav.tsx +++ b/web/src/components/SectionNav.tsx @@ -52,7 +52,7 @@ export function SectionNav({ > Genesis - {SECTIONS.map((s) => ( + {SECTIONS.filter((s) => !s.header).map((s) => ( { + try { + return data.schedules.reduce((sum, s) => sum + BigInt(s.claimable), 0n).toString() + } catch { + return null + } + })() + const firstCliff = data.schedules.reduce( (a, s) => (a === null || s.cliff < a ? s.cliff : a), null as string | null, @@ -215,6 +227,11 @@ export function VestingPanel({ value={outstanding === null ? '—' : amount(outstanding)} note="scheduled less claimed" /> + +

+ The step at the left is not the whole story told small:{' '} + the grants do not share a cliff. Read the table for which ones have + started — a curve that rises early and then sits flat for a year is two grants vesting + from launch while the rest wait, not a gentle beginning. +

@@ -275,6 +298,7 @@ export function VestingPanel({ Grant Vested + Claimable Claimed Unlocks @@ -294,6 +318,7 @@ export function VestingPanel({ {tokens(s.total, decimals, 0)} {tokens(s.vested, decimals, 0)} + {tokens(s.claimable, decimals, 0)} {tokens(s.claimed, decimals, 0)} {day(s.cliff)} {day(s.end)} diff --git a/web/src/index.css b/web/src/index.css index 47f05fd..916635a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1267,6 +1267,90 @@ tr.mine .share-bar > i { padding-left: 62px; } +/* The About link, in the header beside the connection light. Quieter than a + section link: it is about the site rather than the chain, and it should not + compete with the nav below it for a reader's first glance. */ +.about-link { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); + padding: 4px 2px; +} + +.about-link:hover, +.about-link[aria-current='page'] { + color: var(--data-bright); +} + +.about-prose { + padding: 16px 18px; + max-width: 68ch; + line-height: 1.65; +} + +.about-prose p { + margin: 0 0 14px; +} + +.about-prose p:last-child { + margin-bottom: 0; +} + +.about-prose a { + color: var(--data-bright); + text-decoration: underline; + text-decoration-color: var(--border-strong); + text-underline-offset: 3px; +} + +.about-donate { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + align-items: flex-start; + padding: 16px 18px; +} + +.about-donate .about-prose { + padding: 0; + flex: 1 1 320px; +} + +/* The address, whole and selectable. Never truncated: this is the one string on + the site where an abbreviation would be worse than useless, because a reader + copying `qzkQ…rre4` loses the money. */ +.about-address { + display: inline-block; + font-size: 12px; + line-height: 1.6; + word-break: break-all; + background: var(--data-wash); + border: 1px solid var(--border); + padding: 8px 10px; + color: var(--text-primary); +} + +/* The QR carries its own light background rather than inheriting the surface: + a code drawn in the accent hue on warm-black does not scan, whatever the + contrast checker says about text. Scanners want dark-on-light. */ +.qr { + width: 172px; + height: 172px; + flex: 0 0 auto; + display: block; + border: 1px solid var(--border); +} + +.qr-light { + fill: #ffffff; +} + +.qr-dark { + fill: #000000; +} + /* A meter: one ratio against its limit. * * The track is the same hue at wash weight rather than a neutral grey, so the diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index 8192a9a..544b801 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -117,6 +117,7 @@ export type SectionName = | 'network' | 'wormhole' | 'vesting' + | 'about' /** * The sections, in the order the nav shows them. @@ -125,7 +126,7 @@ export type SectionName = * a second page listing the same rows under a different address would be two * answers to one question. `/:chain/miner/` redirects there. */ -export const SECTIONS: { id: SectionName; label: string }[] = [ +export const SECTIONS: { id: SectionName; label: string; header?: boolean }[] = [ { id: 'block', label: 'Blocks' }, { id: 'call', label: 'Calls' }, { id: 'event', label: 'Events' }, @@ -147,6 +148,10 @@ export const SECTIONS: { id: SectionName; label: string }[] = [ // Last, and the only section that is about the future: every other page here // reports what the chain has already done. { id: 'vesting', label: 'Vesting' }, + // `header: true` keeps it out of the section nav. It is not a view of the + // chain — it is about the site — so it sits beside the connection light + // instead, and is listed here only so the URL parses. + { id: 'about', label: 'About', header: true }, ] function section(name: string): SectionName | null {