feat: an about route, and somewhere to say thanks
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:
27
CLAUDE.md
27
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
8
web/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
122
web/src/components/AboutPanel.tsx
Normal file
122
web/src/components/AboutPanel.tsx
Normal 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'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user