feat: an about route, and somewhere to say thanks
All checks were successful
deploy / build (push) Successful in 8m29s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 15s

Adds `/:chain/about`, linked from the header just before the connection light
and the theme toggle — it is about the site rather than the chain, so it sits
there instead of in the section nav, and `header: true` on its `SECTIONS` entry
keeps it out while still letting the URL parse.

The page says what the observer is, that it and the CUDA-optimised miner are
built and freely sourced by a member of the Quantus user community rather than
by the project, and takes donations in QTC.

**The QR is generated in the browser from the address printed beside it, never
shipped as an image.** A supplied PNG next to 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 — a mismatch sends funds somewhere
unrecoverable.

Verified rather than trusted. The address was put through this crate's own SS58
decoder: checksum and network prefix both hold, and it round-trips to the same
string. The QR matrix was then decoded from first principles — function-pattern
map, mask 3 unapplied, codewords de-interleaved across version 4-M's two blocks
— and reads back the address exactly. A note for whoever checks this again: an
independent encoder produces a different matrix for the same string at every
mask, because mode segmentation differs, so comparing matrices proves nothing
and only a decode does.

`the_donation_address_on_the_site_is_a_valid_quantus_address` reads the literal
out of the frontend source and validates it, so a typo fails CI rather than
costing somebody their money. Confirmed to fail on one changed character.

Also on the vesting page, from verifying it live: a "claimable now" figure and
a per-grant claimable column. Two grants began vesting at launch while the other
forty-six wait for 2027, so 68,871 QTC is already claimable and none of it has
been taken — a page that only said "first claimable" would have missed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 16:48:21 +03:00
parent 2bbe8e4d5d
commit 6ed9a6c35a
10 changed files with 316 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"

8
web/pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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() {
</nav>
)}
{/* 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 && (
<Link
to={href({ chain, index: 'about' })}
className="about-link"
aria-current={route.index === 'about' ? 'page' : undefined}
>
About
</Link>
)}
<div className={`status status-${state.connection}`}>
<span className="status-dot" />
{state.connection}
@@ -285,6 +298,7 @@ export default function App() {
<RuntimesIndex chain={chain} current={state.summary?.spec_version ?? null} />
)}
{route.index === 'node' && chain && <NodesIndex chain={chain} />}
{route.index === 'about' && <AboutPanel />}
{route.index === 'vesting' && chain && (
<VestingPanel
chain={chain}

View File

@@ -0,0 +1,122 @@
/**
* Who made this, and how to say thanks.
*
* The QR is **generated in the browser from the same string the page prints**,
* never shipped as an image. A supplied PNG and the text beneath it are two
* independent claims about where money should go, and nothing on the page could
* tell you if they ever stopped agreeing — a mismatch sends funds somewhere
* unrecoverable. Generated, they cannot disagree: there is one address, and the
* code is a rendering of it.
*/
import qrcode from 'qrcode-generator'
/** Where donations go. Also the string the QR is built from — see above. */
export const DONATION_ADDRESS = 'qzkQq9ybiPm7gQR9E1ViQbW9RhqA2Rex9ZQkScU5zdJqjrre4'
const MINER_REPO = 'https://git.lair.cafe/quantus/miner'
const OBSERVER_REPO = 'https://git.lair.cafe/quantus/blackbeard.observer'
/**
* The address as a QR, drawn as one SVG path.
*
* Error-correction level `M` and an automatic version: the address is 49
* characters, which fits comfortably and leaves the code readable at the size
* a phone is held from a screen. Rendered as a single path of rectangles rather
* than a grid of elements — one node instead of a thousand, and it scales to
* whatever box the layout gives it.
*/
function AddressQr({ value }: { value: string }) {
const qr = qrcode(0, 'M')
qr.addData(value)
qr.make()
const count = qr.getModuleCount()
// A four-module quiet zone, which the spec requires and scanners rely on.
const quiet = 4
const size = count + quiet * 2
let path = ''
for (let row = 0; row < count; row += 1) {
for (let col = 0; col < count; col += 1) {
if (qr.isDark(row, col)) {
path += `M${col + quiet} ${row + quiet}h1v1h-1z`
}
}
}
return (
<svg
className="qr"
viewBox={`0 0 ${size} ${size}`}
role="img"
aria-label={`QR code for the donation address ${value}`}
>
{/* 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. */}
<rect x="0" y="0" width={size} height={size} className="qr-light" />
<path d={path} className="qr-dark" />
</svg>
)
}
export function AboutPanel() {
return (
<>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">About</h2>
<span className="eyebrow">who made this</span>
</div>
<div className="about-prose">
<p>
<strong>blackbeard.observer</strong> 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.
</p>
<p>
The same author maintains a{' '}
<a href={MINER_REPO} rel="noreferrer">
CUDA performance-optimised miner
</a>
, 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.
</p>
<p>
Everything the observer shows is derived from public block headers, chain state and the
network&apos;s own telemetry feed. The code that derives it is at{' '}
<a href={OBSERVER_REPO} rel="noreferrer">
git.lair.cafe/quantus/blackbeard.observer
</a>
, so any figure here can be traced to the read that produced it.
</p>
</div>
</section>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Donations</h2>
<span className="eyebrow">entirely optional</span>
</div>
<div className="about-donate">
<AddressQr value={DONATION_ADDRESS} />
<div className="about-prose">
<p>
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:
</p>
<p>
<code className="about-address">{DONATION_ADDRESS}</code>
</p>
<p className="panel-note" style={{ padding: 0, border: 0 }}>
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.
</p>
</div>
</div>
</section>
</>
)
}

View File

@@ -52,7 +52,7 @@ export function SectionNav({
>
Genesis
</Link>
{SECTIONS.map((s) => (
{SECTIONS.filter((s) => !s.header).map((s) => (
<Link
key={s.id}
to={href({ chain, index: s.id })}

View File

@@ -191,6 +191,18 @@ export function VestingPanel({
return null
}
})()
// Vested and not yet taken, summed across every grant. The headline figure on
// this chain today: two grants began vesting at launch while the other
// forty-six wait for 2027, so a page that only said "first claimable" would
// miss that a great deal already is.
const claimableNow = (() => {
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"
/>
<Field
label="Claimable now"
value={claimableNow === null ? '—' : amount(claimableNow)}
note="vested and not yet taken"
/>
<Field label="Grants" value={fmtHeight(data.schedules.length)} />
<Field
label="First claimable"
@@ -258,6 +275,12 @@ export function VestingPanel({
whole grant from its end, and linearly in between so this is that arithmetic summed
across every schedule, not a model of what somebody intends.
</p>
<p className="panel-note">
The step at the left is not the whole story told small:{' '}
<strong>the grants do not share a cliff</strong>. 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.
</p>
</section>
<section className="panel" style={{ marginBottom: 26 }}>
@@ -275,6 +298,7 @@ export function VestingPanel({
</th>
<th scope="col">Grant</th>
<th scope="col">Vested</th>
<th scope="col">Claimable</th>
<th scope="col">Claimed</th>
<th scope="col" className="left">
Unlocks
@@ -294,6 +318,7 @@ export function VestingPanel({
</td>
<td className="numeral">{tokens(s.total, decimals, 0)}</td>
<td className="numeral">{tokens(s.vested, decimals, 0)}</td>
<td className="numeral">{tokens(s.claimable, decimals, 0)}</td>
<td className="numeral">{tokens(s.claimed, decimals, 0)}</td>
<td className="left">{day(s.cliff)}</td>
<td className="left">{day(s.end)}</td>

View File

@@ -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

View File

@@ -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 {