diff --git a/CLAUDE.md b/CLAUDE.md index 0d14333..83cacdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -785,6 +785,37 @@ One mark stands for many and the caption always says how many, printed whether or not a block has arrived yet: forty-five turning marks with nothing explaining them is worse than no marks at all. +**A mark is not self-explanatory, so every mark gets a line.** A dot travelling +inward could be anybody paying anybody; without text beside it the stage is an +ornament. `ServerMessage::Wormhole` therefore carries a *sample* of the +individual notes — kind, recipient, amount — and the console below the stage +lists them newest-first, the way the block ticker reads, which also means no +auto-scroll to fight and the line a reader is looking for never moves. + +`kind` is the third split this codebase makes on the producing extrinsic, and +the only one that separates all three cases: `reward` has **no extrinsic at +all** (block initialisation paying a miner), `transfer` has an ordinary one, and +`exit` has a `verify_*_batch`. The recorded sender distinguishes none of them — +it is the same sentinel for a reward and an exit both. + +Two caps, and they interact: + +- `MAX_PULSE_NOTES` bounds the wire. A block has carried hundreds of notes and + sending every one to draw a handful of lines is kilobytes a block for text + nobody reads. The **counts beside them stay complete**. +- `CONSOLE_LINES_PER_BLOCK` bounds one block's share of the console. Without it + a single busy block fills all twelve lines and twelve lines from one block + look exactly like twelve blocks of one — the reader loses all sense of rate. + +The count of what is not shown hangs on the block's **first** line, not its +last. The list is newest-first, so a count on the last line is the first thing +`CONSOLE_LINES` trims, and a block of 260 would show four notes and never say +so. That bug shipped to a screenshot before it was caught. + +The console is fed **before** the `reduced` guard, so a viewer who asked for +less motion still gets the whole reading — verified by forcing the hook: twelve +lines, zero moving marks, population still drawn, caption still live. + Three rules keep `WormholeStage` honest, and each cost a rewrite: - **Marks are capped; the caption is not.** A block committing 900 notes draws diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index 8e575f3..b4e6f47 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -1055,6 +1055,13 @@ async fn index_events( /// A block that touched no notes broadcasts nothing. On a chain producing a /// block every twelve seconds an empty pulse would be pure noise, and a client /// that sees silence correctly draws nothing. +/// Individual notes carried on one pulse. +/// +/// Enough to fill the console a few times over at a block a second; far short +/// of the hundreds a busy block can carry, which is the point — the counts +/// beside them are complete and these are only what gets a line of text. +const MAX_PULSE_NOTES: usize = 12; + fn broadcast_wormhole_pulse( chain: &Arc, height: u64, @@ -1063,6 +1070,7 @@ fn broadcast_wormhole_pulse( ) { let (mut notes_in, mut notes_out) = (0u32, 0u32); let (mut amount_in, mut amount_out) = (0u128, 0u128); + let mut notes: Vec = Vec::new(); for event in decoded { if event.pallet != "Wormhole" || event.variant != "NativeTransferred" { @@ -1081,13 +1089,40 @@ fn broadcast_wormhole_pulse( }) .unwrap_or(0); - if event.extrinsic_index.is_some_and(|i| exits.contains(&i)) { + // Three kinds, told apart by the producing extrinsic. A reward is + // emitted in the block's own initialisation phase and so carries no + // extrinsic at all; the recorded sender cannot distinguish any of them, + // because it is the same sentinel for a reward and an exit both. + let kind = match event.extrinsic_index { + Some(i) if exits.contains(&i) => "exit", + Some(_) => "transfer", + None => "reward", + }; + + if kind == "exit" { notes_out = notes_out.saturating_add(1); amount_out = amount_out.saturating_add(amount); } else { notes_in = notes_in.saturating_add(1); amount_in = amount_in.saturating_add(amount); } + + // A sample. The counts above stay complete, and the page says how many + // it is not showing — a block has carried hundreds of these. + if notes.len() < MAX_PULSE_NOTES { + let to = event + .fields + .get("to") + .and_then(|v| v.as_str()) + .and_then(blackbeard_core::wormhole::ss58_of); + if let Some(to) = to { + notes.push(blackbeard_entities::WormholeNote { + kind: kind.to_string(), + to, + amount: blackbeard_entities::BigUintDec(amount.to_string()), + }); + } + } } if notes_in == 0 && notes_out == 0 { @@ -1102,6 +1137,7 @@ fn broadcast_wormhole_pulse( notes_out, amount_in: blackbeard_entities::BigUintDec(amount_in.to_string()), amount_out: blackbeard_entities::BigUintDec(amount_out.to_string()), + notes, }, None, ); diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index b13af62..ff6aeca 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -49,7 +49,7 @@ pub use runtime::{ }; pub use series::{ChainSeries, ChainSeriesPoint}; pub use state::{ChainState, StateEntry}; -pub use ws::{ClientMessage, ServerMessage, Window}; +pub use ws::{ClientMessage, ServerMessage, Window, WormholeNote}; use serde::{Deserialize, Serialize}; use ts_rs::TS; diff --git a/crates/blackbeard-entities/src/ws.rs b/crates/blackbeard-entities/src/ws.rs index 26d20f0..42a07c8 100644 --- a/crates/blackbeard-entities/src/ws.rs +++ b/crates/blackbeard-entities/src/ws.rs @@ -127,6 +127,31 @@ pub enum ClientMessage { Ping, } +/// One note a block committed or released. +/// +/// The reason this exists: the stage draws a mark per note and a mark is not +/// self-explanatory. A dot travelling inward could be anybody paying anybody, +/// and without a line of text beside it the animation is decoration. This is +/// what turns it back into a reading of the chain. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "WormholeNote.ts")] +pub struct WormholeNote { + /// What put it there. One of: + /// + /// - `reward` — block initialisation paying a miner. The "sender" is the + /// runtime rather than anybody. + /// - `transfer` — an ordinary extrinsic, whose sender the chain recorded. + /// - `exit` — a settled batch releasing value, whose sender it did not. + /// + /// Taken from the producing extrinsic, never from the recorded sender: that + /// is the same sentinel for a reward and for an exit both. + pub kind: String, + /// Who it credits, SS58. + pub to: String, + /// How much, smallest unit. + pub amount: BigUintDec, +} + /// Server to browser. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] @@ -217,6 +242,14 @@ pub enum ServerMessage { amount_in: BigUintDec, /// Value leaving, smallest unit. amount_out: BigUintDec, + /// Individual notes, so the page can say what each mark stands for. + /// + /// **A sample, capped — the counts above are complete.** A block has + /// carried hundreds of notes, and sending every one to draw a handful + /// of lines would put kilobytes on the wire per block for text nobody + /// reads. Anything rendering these has to say how many it is not + /// showing. + notes: Vec, }, /// A chain's reachability changed — the node went away, or a chain that was /// awaiting launch has started producing blocks. diff --git a/web/src/api/generated/ServerMessage.ts b/web/src/api/generated/ServerMessage.ts index f8f1dde..89e7158 100644 --- a/web/src/api/generated/ServerMessage.ts +++ b/web/src/api/generated/ServerMessage.ts @@ -6,6 +6,7 @@ import type { ChainSummary } from "./ChainSummary"; import type { LeaderboardRow } from "./LeaderboardRow"; import type { RecentBlock } from "./RecentBlock"; import type { Window } from "./Window"; +import type { WormholeNote } from "./WormholeNote"; /** * Server to browser. @@ -102,7 +103,17 @@ amount_in: BigUintDec, /** * Value leaving, smallest unit. */ -amount_out: BigUintDec, } | { "type": "chain_status", +amount_out: BigUintDec, +/** + * Individual notes, so the page can say what each mark stands for. + * + * **A sample, capped — the counts above are complete.** A block has + * carried hundreds of notes, and sending every one to draw a handful + * of lines would put kilobytes on the wire per block for text nobody + * reads. Anything rendering these has to say how many it is not + * showing. + */ +notes: Array, } | { "type": "chain_status", /** * Which chain. */ diff --git a/web/src/api/generated/WormholeNote.ts b/web/src/api/generated/WormholeNote.ts new file mode 100644 index 0000000..3c6e6d2 --- /dev/null +++ b/web/src/api/generated/WormholeNote.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BigUintDec } from "./BigUintDec"; + +/** + * One note a block committed or released. + * + * The reason this exists: the stage draws a mark per note and a mark is not + * self-explanatory. A dot travelling inward could be anybody paying anybody, + * and without a line of text beside it the animation is decoration. This is + * what turns it back into a reading of the chain. + */ +export type WormholeNote = { +/** + * What put it there. One of: + * + * - `reward` — block initialisation paying a miner. The "sender" is the + * runtime rather than anybody. + * - `transfer` — an ordinary extrinsic, whose sender the chain recorded. + * - `exit` — a settled batch releasing value, whose sender it did not. + * + * Taken from the producing extrinsic, never from the recorded sender: that + * is the same sentinel for a reward and for an exit both. + */ +kind: string, +/** + * Who it credits, SS58. + */ +to: string, +/** + * How much, smallest unit. + */ +amount: BigUintDec, }; diff --git a/web/src/api/socket.ts b/web/src/api/socket.ts index a13b25e..47ea62c 100644 --- a/web/src/api/socket.ts +++ b/web/src/api/socket.ts @@ -18,6 +18,7 @@ import type { ClientMessage } from './generated/ClientMessage' import type { LeaderboardRow } from './generated/LeaderboardRow' import type { RecentBlock } from './generated/RecentBlock' import type { ServerMessage } from './generated/ServerMessage' +import type { WormholeNote } from './generated/WormholeNote' import type { Window as WindowName } from './generated/Window' /** How the socket is doing, for the status light in the header. */ @@ -78,6 +79,13 @@ export interface WormholePulse { notesOut: number amountIn: string amountOut: string + /** + * A sample of the individual notes, for the console beside the stage. + * + * Capped by the server. `notesIn + notesOut` is the complete count, and + * anything rendering these has to say how many it is not showing. + */ + notes: WormholeNote[] } /** Blocks kept in the ticker. Matches the backend's replay length. */ @@ -282,6 +290,7 @@ export class Observer { notesOut: message.notes_out, amountIn: message.amount_in, amountOut: message.amount_out, + notes: message.notes, } // A throwing subscriber must not take the socket's reader loop with // it: the rest of the site depends on this loop, and an animation is diff --git a/web/src/components/WormholeStage.tsx b/web/src/components/WormholeStage.tsx index eacb602..c08e10f 100644 --- a/web/src/components/WormholeStage.tsx +++ b/web/src/components/WormholeStage.tsx @@ -29,7 +29,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { fetchWormhole } from '../api/rest' import type { WormholePulse } from '../api/socket' -import { tokens } from '../lib/format' +import { shortAddress, tokens } from '../lib/format' import { useObserverInstance } from '../lib/observer-context' const HEIGHT = 210 @@ -65,9 +65,54 @@ const STOCK_MARKS = 45 const STOCK_SHELLS = 3 /** How often the standing population is re-read. It moves slowly; this is not a feed. */ const STOCK_REFRESH_MS = 120_000 + +/** + * Lines kept in the console. + * + * The stage's marks are not self-explanatory — a dot travelling inward could be + * anybody paying anybody — so each one gets a line saying what it was. Ten is + * about half a minute of mainnet, which is long enough to read and short enough + * that the oldest line is still recent. + */ +const CONSOLE_LINES = 12 + +/** + * Lines any one block may contribute. + * + * Without it a single busy block fills the console and the reader loses all + * sense of rate — one mainnet block has carried hundreds of notes, and twelve + * lines from it look identical to twelve blocks of one. Four keeps several + * blocks on screen, and the count of what is not shown sits on the block's own + * first line. + */ +const CONSOLE_LINES_PER_BLOCK = 4 /** Most marks any single block may spawn, so one big block cannot fill the pool. */ const PER_PULSE_CAP = 28 +/** + * What each kind of arrival is, in words a reader has not had to look up. + * + * The glyph repeats the direction the mark travelled, so a line and the dot it + * describes can be matched without reading the label — the same + * position-over-colour reasoning the flow chart uses. + */ +const KINDS: Record = { + reward: { glyph: '\u2193', label: 'mined' }, + transfer: { glyph: '\u2193', label: 'sent' }, + exit: { glyph: '\u2191', label: 'exit' }, +} + +interface ConsoleLine { + /** Unique per line, so React is not asked to key on a block that has several. */ + id: string + height: number + kind: string + to: string + amount: string + /** Notes this block carried that got no line of their own. */ + elided: number +} + interface Particle { /** Set while in flight; a free slot is `null`. */ born: number @@ -139,6 +184,8 @@ export function WormholeStage({ * and look authoritative doing it. */ const [outstanding, setOutstanding] = useState(null) + /** Newest first, the way the block ticker reads. Oldest falls off the end. */ + const [log, setLog] = useState([]) useEffect(() => { if (chain === null) return @@ -291,7 +338,6 @@ export function WormholeStage({ useEffect(() => { return observer.onWormhole((pulse: WormholePulse) => { setLast(pulse) - if (reduced) return const spawn = (count: number, out: boolean, total: string) => { const draw = Math.min(count, PER_PULSE_CAP) @@ -326,6 +372,30 @@ export function WormholeStage({ } } + // The console is fed whether or not the marks are, so a viewer who has + // asked for less motion still gets the reading. + const total = pulse.notesIn + pulse.notesOut + const shown = pulse.notes.slice(0, CONSOLE_LINES_PER_BLOCK) + const elided = Math.max(0, total - shown.length) + setLog((previous) => + [ + ...shown.map((n, i) => ({ + id: `${pulse.height}-${i}`, + height: pulse.height, + kind: n.kind, + to: n.to, + amount: n.amount, + // On the block's *first* line, which is the one that survives: the + // list is newest-first, so a count hung on the last line would be + // the first thing trimmed by `CONSOLE_LINES` and the reader would + // never learn a block of 260 showed them four. + elided: i === 0 ? elided : 0, + })), + ...previous, + ].slice(0, CONSOLE_LINES), + ) + + if (reduced) return spawn(pulse.notesIn, false, pulse.amountIn) spawn(pulse.notesOut, true, pulse.amountOut) start.current() @@ -454,6 +524,45 @@ export function WormholeStage({ )} {reduced && motion off} + + {/* What the marks were. The stage on its own is a dot travelling inward, + which could be anybody paying anybody; this is the half that makes it + a reading of the chain rather than an ornament. Newest at the top, the + way the block ticker reads — which also means no auto-scrolling to + fight with, and the line a reader is looking for never moves. */} +
    + {log.length === 0 ? ( +
  1. + Each note a block commits or releases will be listed here as it happens. +
  2. + ) : ( + log.map((line, i) => { + const kind = KINDS[line.kind] ?? { glyph: '·', label: line.kind } + // A block's other lines leave the height column blank, so a run of + // notes reads as one block rather than as a column of repetition. + const repeat = i > 0 && log[i - 1]?.height === line.height + return ( +
  3. + + {repeat ? '' : `#${line.height.toLocaleString('en-US')}`} + + + {kind.glyph} {kind.label} + + + {tokens(line.amount, decimals, 4)} {symbol} + + {shortAddress(line.to)} + {line.elided > 0 && ( + + +{line.elided.toLocaleString('en-US')} more in this block + + )} +
  4. + ) + }) + )} +
) } diff --git a/web/src/index.css b/web/src/index.css index 98e3d86..fa29016 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1141,6 +1141,66 @@ tr.mine .share-bar > i { color: var(--text-muted); } +/* The console beside the stage: what each mark was. + * + * Monospace and columnar so the eye can run down one field — a log that + * reflows between lines is a log nobody scans. Older lines recede rather than + * vanishing at a hard edge, which is what makes the buffer read as a buffer. */ +.stage-log { + list-style: none; + margin: 0; + padding: 2px 12px 10px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.75; +} + +.stage-log li { + display: flex; + flex-wrap: wrap; + gap: 0 12px; + align-items: baseline; + color: var(--text-secondary); + white-space: nowrap; +} + +/* The newest line is the one being read; the rest fade back in order. Applied + to the tail rather than to every line so the top of the list stays at full + strength as it grows. */ +.stage-log li:nth-child(n + 5) { + opacity: 0.72; +} + +.stage-log li:nth-child(n + 8) { + opacity: 0.45; +} + +.stage-log-empty { + color: var(--text-muted); +} + +.stage-log-height { + color: var(--text-muted); + min-width: 66px; +} + +/* Direction repeats what the mark did, in a glyph, so a line and its dot can be + matched without reading the label. Same reasoning as the flow chart: the + encoding is geometry, never a second hue — every kind wears the one colour. */ +.stage-log-kind { + min-width: 72px; + color: var(--data-bright); +} + +.stage-log-amount { + min-width: 132px; + color: var(--text-primary); +} + +.stage-log-to { + color: var(--text-muted); +} + /* A meter: one ratio against its limit. * * The track is the same hue at wash weight rather than a neutral grey, so the