feat: identify miners by the address they are paid at, not the preimage
deploy / build (push) Waiting to run
deploy / deploy-api (push) Has been cancelled
deploy / deploy-web (push) Has been cancelled

A reward preimage is exact and unrecognisable — no miner knows theirs by sight.
The address is the string they configured and the one their balance shows
against, and the two are related by a derivation the chain performs anyway:
`pallets/mining-rewards` pays `qp_wormhole::derive_wormhole_address`, which is
`qp_poseidon_core::rehash_to_bytes`. Running the same function reproduces the
address exactly, with no RPC and no lookup table.

The derivation takes the preimage and nothing else — no genesis, no chain
parameters — so one preimage is one account on every Quantus chain. The unit
test pins it against a real pair (this observer's own node and the address it
was paid at), so a dependency upgrade that changed the derivation fails the
build rather than quietly relabelling every miner on the site.

Unnamed miners now render as their address. The miner panel shows all three
identifiers, because they answer different questions: the node name is an
inference and may be absent, the address is what the miner recognises, and the
preimage is what the header actually carries and the only one the chain
asserts.

SS58 is written out rather than pulled from `sp-core`, which would bring a
substrate runtime's worth of dependencies to format 36 bytes — the same trade
the readme records for declining subxt. Base58 is hand-rolled because it is
long division and nothing else; blake2 is not, because checksums are not worth
hand-rolling. Both are covered by the address test vector.

`RecentBlock` gains the attribution source: the ticker cannot tell a node name
from an address by looking, since `quanpool-payout-mainnet` and `quantus-radar`
both begin with the letter Quantus addresses do. Two tests that asserted the
old display policy now assert the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-09 15:13:25 +03:00
co-authored by Claude Opus 5
parent ffcfb79cab
commit a3eb670d11
20 changed files with 355 additions and 34 deletions
+16
View File
@@ -123,6 +123,22 @@ nothing about mainnet, which targets 12 s.
`/tmp/env-config.js` on the telemetry site, **not** the `/feed` path on the
main host, which 404s.
**A miner's reward address is derivable from its preimage, offline.**
`pallets/mining-rewards` pays `qp_wormhole::derive_wormhole_address(preimage)`,
which is `qp_poseidon_core::rehash_to_bytes` — Poseidon2 over Goldilocks.
`blackbeard-core::wormhole` runs the same function and SS58-encodes it with
prefix 189. It takes the preimage and **nothing else**: no genesis, no chain
parameters, so one preimage is one address on every Quantus chain. That is also
what makes carrying a name across chains sound. The test pins it against a real
pair (`baba-gorchitsa`), so a `qp-poseidon-core` upgrade that changed the
derivation fails the build rather than silently relabelling every miner.
An unnamed miner therefore renders as its **address**, not its preimage — both
identify it exactly, and only one is a string its owner has ever seen. Do not
infer "is this a name or an address?" from the string: several real node names
begin with `q`, so `RecentBlock` and `LeaderboardRow` carry the
`AttributionSource` and the UI reads that.
## The frontend's types are generated
`web/src/api/generated/` is written by `ts-rs` from `blackbeard-entities` when
Generated
+17
View File
@@ -276,9 +276,11 @@ name = "blackbeard-core"
version = "0.1.0"
dependencies = [
"blackbeard-entities",
"blake2",
"chrono",
"hex",
"primitive-types",
"qp-poseidon-core",
"thiserror 2.0.20",
"tracing",
]
@@ -315,6 +317,15 @@ dependencies = [
"ts-rs",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -1538,6 +1549,12 @@ dependencies = [
"yansi",
]
[[package]]
name = "qp-poseidon-core"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5872607e25ea4ee5fb37e64bf1462168e1a36a4e719cdc8a105533c708253918"
[[package]]
name = "quinn"
version = "0.11.11"
+5
View File
@@ -22,6 +22,11 @@ clap = { version = "4", features = ["derive", "env"] }
figment = { version = "0.10", features = ["toml", "env"] }
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
hex = "0.4"
# The chain's own reward-address derivation, so the observer computes the same
# account `pallets/mining-rewards` pays rather than guessing or asking.
qp-poseidon-core = { version = "3.1.0", default-features = false }
# SS58 checksums only. Base58 is hand-rolled; this is not.
blake2 = { version = "0.10", default-features = false }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
serde = { version = "1", features = ["derive"] }
+4 -4
View File
@@ -321,14 +321,14 @@ async fn record(
inner.height = Some(height);
inner.last_block_at = Some(observed_at);
let display = inner
let attributed = inner
.attributor
.attribute(&miner, |key| chain.telemetry.name_of(key))
.display;
.attribute(&miner, |key| chain.telemetry.name_of(key));
let block = RecentBlock {
height,
miner: miner.clone(),
display,
display: attributed.display,
attribution: attributed.source,
observed_at,
gap_seconds,
};
+1
View File
@@ -538,6 +538,7 @@ async fn miner(
Ok(Json(MinerDetail {
chain: runtime.id(),
address: blackbeard_core::wormhole::reward_address(&miner),
miner,
current,
blocks_observed,
+1 -1
View File
@@ -342,7 +342,7 @@ impl ChainRuntime {
let telemetry = &self.telemetry;
let rows = blackbeard_core::window::leaderboard(&tallies, total, network, |miner| {
let a = attributor.attribute(miner, |key| telemetry.name_of(key));
(a.display, a.source, a.confidence, a.votes)
(a.display, a.address, a.source, a.confidence, a.votes)
});
let changed = leaderboards.get(&window).map(|(_, cached)| cached) != Some(&rows);
+2
View File
@@ -11,7 +11,9 @@ repository.workspace = true
[dependencies]
blackbeard-entities.workspace = true
chrono.workspace = true
blake2.workspace = true
hex.workspace = true
qp-poseidon-core.workspace = true
primitive-types.workspace = true
thiserror.workspace = true
tracing.workspace = true
+49 -7
View File
@@ -97,6 +97,9 @@ pub enum NodeKey {
pub struct Attribution {
/// What to render.
pub display: String,
/// The address this miner is paid at, derived from its preimage. `None`
/// only if the preimage is not a canonical field encoding.
pub address: Option<String>,
/// Whether `display` is an inferred node name or the abbreviated preimage.
pub source: AttributionSource,
/// Fraction of the cast votes agreeing with the held name, 0.0–1.0.
@@ -120,6 +123,10 @@ pub struct Attributor {
/// Names this operator earned on another chain, keyed by reward preimage.
/// Consulted only when no live attribution has been held on this one.
carried: HashMap<MinerId, String>,
/// Derived reward addresses, memoised. The derivation is a Poseidon2 hash
/// and the leaderboard re-renders every few seconds for every miner, so it
/// is computed once per author rather than once per row per refresh.
addresses: HashMap<MinerId, Option<String>>,
authors: HashMap<MinerId, AuthorVotes>,
}
@@ -211,18 +218,32 @@ impl Attributor {
where
F: Fn(&NodeKey) -> Option<String>,
{
let address = self
.addresses
.entry(miner.clone())
.or_insert_with(|| crate::wormhole::reward_address(miner))
.clone();
// A name carried from another chain stands in until this chain's feed
// names the miner itself — better than an abbreviated preimage, and
// labelled so nobody reads it as a live observation.
// names the miner itself — better than an address, and labelled so
// nobody reads it as a live observation.
//
// Failing that, the payout address rather than the preimage. Both
// identify the miner exactly; only one of them is a string its owner
// has ever seen.
let fallback = match self.carried.get(miner) {
Some(name) => Attribution {
display: name.clone(),
address: address.clone(),
source: AttributionSource::Carried,
confidence: 0.0,
votes: 0,
},
None => Attribution {
display: miner.abbreviated(),
display: address
.as_deref()
.map_or_else(|| miner.abbreviated(), crate::wormhole::abbreviated_address),
address: address.clone(),
source: AttributionSource::Preimage,
confidence: 0.0,
votes: 0,
@@ -294,6 +315,7 @@ impl Attributor {
match name_of(&held) {
Some(name) if !name.is_empty() => Attribution {
display: name,
address,
source: AttributionSource::Telemetry,
confidence,
votes: backing,
@@ -527,12 +549,18 @@ mod tests {
}
#[test]
fn too_few_blocks_shows_the_preimage() {
fn too_few_blocks_shows_the_reward_address() {
// Still `Preimage` — no node name is known — but what renders is the
// address the miner is paid at, not the preimage. Both identify it
// exactly; only one is a string its owner has ever seen. (This asserted
// `abbreviated()` before the address derivation existed.)
let mut a = Attributor::new();
a.observe(&miner(), Some((NodeKey::Peer("p".into()), Some(80))));
let got = a.attribute(&miner(), named);
assert_eq!(got.source, AttributionSource::Preimage);
assert_eq!(got.display, miner().abbreviated());
let address = crate::wormhole::reward_address(&miner()).expect("derives");
assert_eq!(got.display, crate::wormhole::abbreviated_address(&address));
assert_eq!(got.address.as_deref(), Some(address.as_str()));
}
#[test]
@@ -651,8 +679,22 @@ mod tests {
}
#[test]
fn an_unknown_miner_is_its_preimage() {
fn an_unknown_miner_is_its_reward_address() {
let mut a = Attributor::new();
assert_eq!(a.attribute(&miner(), named).display, miner().abbreviated());
let address = crate::wormhole::reward_address(&miner()).expect("derives");
assert_eq!(
a.attribute(&miner(), named).display,
crate::wormhole::abbreviated_address(&address)
);
}
/// A preimage the derivation refuses still renders as something.
#[test]
fn an_underivable_preimage_falls_back_to_itself() {
let mut a = Attributor::new();
let bad = MinerId("0xnot-a-preimage".into());
let got = a.attribute(&bad, named);
assert_eq!(got.address, None);
assert_eq!(got.display, bad.abbreviated());
}
}
+1
View File
@@ -19,6 +19,7 @@ pub mod hashrate;
pub mod scale;
pub mod series;
pub mod window;
pub mod wormhole;
/// Failures decoding what a node handed us.
#[derive(Debug, thiserror::Error, PartialEq)]
+13 -6
View File
@@ -185,7 +185,7 @@ pub fn leaderboard<F>(
mut attribute: F,
) -> Vec<LeaderboardRow>
where
F: FnMut(&MinerId) -> (String, AttributionSource, f32, u32),
F: FnMut(&MinerId) -> (String, Option<String>, AttributionSource, f32, u32),
{
let mut ranked: Vec<(&MinerId, &MinerTally)> = tallies.iter().collect();
ranked.sort_by(|a, b| {
@@ -202,11 +202,12 @@ where
.into_iter()
.enumerate()
.map(|(i, (miner, tally))| {
let (display, attribution, confidence, attribution_votes) = attribute(miner);
let (display, address, attribution, confidence, attribution_votes) = attribute(miner);
LeaderboardRow {
rank: i as u32 + 1,
miner: miner.clone(),
display,
address,
attribution,
confidence,
attribution_votes,
@@ -380,7 +381,7 @@ mod tests {
}
let (t, total) = w.tally(100);
let rows = leaderboard(&t, total, Some(1000.0), |m| {
(m.abbreviated(), AttributionSource::Preimage, 0.0, 0)
(m.abbreviated(), None, AttributionSource::Preimage, 0.0, 0)
});
assert_eq!(rows.len(), 3);
// 1 and 2 both have two blocks; 2's most recent is higher, so it leads.
@@ -405,7 +406,7 @@ mod tests {
let rows = leaderboard(&t, total, None, |m| {
// Miners 1 and 2 share a name; miner 3 has its own.
let name = if m == &miner(3) { "solo" } else { "rig-01" };
(name.to_owned(), AttributionSource::Telemetry, 1.0, 1)
(name.to_owned(), None, AttributionSource::Telemetry, 1.0, 1)
});
let displays: Vec<&str> = rows.iter().map(|r| r.display.as_str()).collect();
assert_eq!(
@@ -431,7 +432,13 @@ mod tests {
w.push(obs(1, 1, 1), true);
let (t, total) = w.tally(100);
let rows = leaderboard(&t, total, None, |_| {
("alice".to_owned(), AttributionSource::Telemetry, 1.0, 1)
(
"alice".to_owned(),
None,
AttributionSource::Telemetry,
1.0,
1,
)
});
assert_eq!(rows[0].display, "alice");
}
@@ -442,7 +449,7 @@ mod tests {
let (t, total) = w.tally(100);
assert!(w.is_empty());
let rows = leaderboard(&t, total, Some(1000.0), |m| {
(m.abbreviated(), AttributionSource::Preimage, 0.0, 0)
(m.abbreviated(), None, AttributionSource::Preimage, 0.0, 0)
});
assert!(rows.is_empty());
}
+160
View File
@@ -0,0 +1,160 @@
//! Turning a miner's reward preimage into the address it is paid at.
//!
//! The `pow_` digest gives a 32-byte preimage, which is a perfect identifier and
//! completely unrecognisable: no miner knows their own preimage by sight. They
//! know their payout address, because that is the string they configured and the
//! one their balance shows against.
//!
//! The two are related by a derivation the chain itself performs.
//! `pallets/mining-rewards` reads the preimage out of the header and calls
//! `qp_wormhole::derive_wormhole_address` to decide who to pay; that is
//! `qp_poseidon_core::rehash_to_bytes`, a Poseidon2 hash over Goldilocks field
//! elements. Running the same function here reproduces the address exactly,
//! with no RPC and no lookup table.
//!
//! **The derivation takes the preimage and nothing else** — no genesis, no chain
//! parameters — so one preimage is one account on every Quantus chain. That is
//! the same fact that makes carrying a name across chains sound, and it is why
//! an address derived from a testnet block is still correct on mainnet.
//!
//! Deriving an address reveals nothing a header did not already publish.
//! Spending from it needs a plonky2 proof of knowledge of the secret behind the
//! preimage, which the preimage does not give up.
use blackbeard_entities::MinerId;
/// SS58 network prefix for Quantus, from `system_properties` on both Planck and
/// mainnet. Not derived from the chain at runtime: it is a property of the
/// address format the network agreed on, and a chain that changed it would need
/// this revisited deliberately rather than silently following along.
pub const SS58_PREFIX: u16 = 189;
/// Bitcoin's base58 alphabet, which SS58 also uses.
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
/// The reward address a preimage is paid at, SS58-encoded.
///
/// `None` when the preimage is not a canonical field-element encoding. That is
/// possible because the preimage is miner-supplied: the chain itself refuses
/// such a header rather than panicking, and so does this.
pub fn reward_address(miner: &MinerId) -> Option<String> {
let hex = miner.as_str().strip_prefix("0x").unwrap_or(miner.as_str());
let mut preimage = [0u8; 32];
hex::decode_to_slice(hex, &mut preimage).ok()?;
let account = qp_poseidon_core::rehash_to_bytes(&preimage).ok()?;
Some(ss58(&account, SS58_PREFIX))
}
/// SS58-encode a 32-byte account id.
///
/// Prefix, payload, then two checksum bytes over `"SS58PRE" || prefix ||
/// payload`, the whole thing base58-encoded. Written out rather than pulled
/// from `sp-core`, which would bring a substrate runtime's worth of
/// dependencies to format 36 bytes — the same trade the readme records for
/// declining `subxt`.
fn ss58(account: &[u8; 32], prefix: u16) -> String {
let mut raw = Vec::with_capacity(36);
// Prefixes above 63 use the two-byte form; 189 is one of them. The bit
// layout is not obvious and is not worth deriving twice — the test below
// pins it against an address the network itself issued.
if prefix < 64 {
raw.push(prefix as u8);
} else {
raw.push((((prefix & 0b0000_0000_1111_1100) >> 2) | 0b0100_0000) as u8);
raw.push((((prefix >> 8) | ((prefix & 0b0000_0000_0000_0011) << 6)) & 0xFF) as u8);
}
raw.extend_from_slice(account);
use blake2::digest::consts::U64;
use blake2::{Blake2b, Digest};
let mut hasher = Blake2b::<U64>::new();
hasher.update(b"SS58PRE");
hasher.update(&raw);
raw.extend_from_slice(&hasher.finalize()[..2]);
base58(&raw)
}
/// Base58, big-endian, with leading zero bytes preserved as `1`s.
///
/// Hand-rolled because it is long division and nothing else, and a dependency
/// for fifteen lines of arithmetic is a dependency to audit and upgrade
/// forever. The test vector below is a real address the network issued, so a
/// mistake here cannot pass unnoticed.
fn base58(input: &[u8]) -> String {
let mut digits: Vec<u8> = Vec::with_capacity(input.len() * 2);
for &byte in input {
let mut carry = byte as u32;
for digit in digits.iter_mut() {
carry += (*digit as u32) << 8;
*digit = (carry % 58) as u8;
carry /= 58;
}
while carry > 0 {
digits.push((carry % 58) as u8);
carry /= 58;
}
}
// Every leading zero byte is one leading '1', which the arithmetic above
// cannot represent because zero contributes no digits.
let leading = input.iter().take_while(|b| **b == 0).count();
let mut out = String::with_capacity(leading + digits.len());
out.extend(std::iter::repeat_n('1', leading));
out.extend(digits.iter().rev().map(|d| ALPHABET[*d as usize] as char));
out
}
/// The address in the form a table cell can hold, keeping both ends so two
/// addresses cannot be confused by their prefix alone.
pub fn abbreviated_address(address: &str) -> String {
if address.len() > 14 {
format!("{}…{}", &address[..8], &address[address.len() - 4..])
} else {
address.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Pinned against the real pair: `baba-gorchitsa` is the observer's own
/// node, and this is the address it was paid at on the testnet. If a
/// `qp-poseidon-core` upgrade ever changes the derivation, this fails
/// rather than quietly re-labelling every miner on the site.
#[test]
fn a_preimage_derives_the_address_the_chain_pays() {
let miner =
MinerId("0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59".into());
assert_eq!(
reward_address(&miner).as_deref(),
Some("qzp2AxZw8szXc5qDe1aU8P5kt1dkf1JFYktvV7N6cVbFZrs8L")
);
}
#[test]
fn the_prefix_is_accepted_with_or_without_0x() {
let bare =
MinerId("134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59".into());
assert_eq!(
reward_address(&bare).as_deref(),
Some("qzp2AxZw8szXc5qDe1aU8P5kt1dkf1JFYktvV7N6cVbFZrs8L")
);
}
#[test]
fn a_preimage_that_is_not_32_bytes_yields_nothing() {
// Miner-supplied, so this must decline rather than panic — the same
// reason `extract_author_from_digest` does on the chain side.
for bad in ["0x", "0xdeadbeef", "zz", &"0".repeat(63)] {
assert_eq!(reward_address(&MinerId(bad.into())), None, "{bad}");
}
}
#[test]
fn abbreviation_keeps_both_ends() {
let a = abbreviated_address("qzp2AxZw8szXc5qDe1aU8P5kt1dkf1JFYktvV7N6cVbFZrs8L");
assert_eq!(a, "qzp2AxZw…rs8L");
assert_eq!(abbreviated_address("short"), "short");
}
}
+4
View File
@@ -51,6 +51,10 @@ pub struct RecentBlock {
pub miner: MinerId,
/// Display name for the author at the time the block was pushed.
pub display: String,
/// Where `display` came from. Carried because the ticker cannot tell a node
/// name from a derived reward address by looking at the string — several
/// real node names begin with the same letter Quantus addresses do.
pub attribution: AttributionSource,
/// When this observer first saw it.
pub observed_at: DateTime<Utc>,
/// Seconds since the previous block, by observation. `None` for the first
+8
View File
@@ -88,6 +88,11 @@ pub struct LeaderboardRow {
pub rank: u32,
/// The miner's reward preimage — the stable key for this row.
pub miner: MinerId,
/// The address this miner is paid at, derived from the preimage by the same
/// function the chain uses to decide who to pay. Shown in place of the
/// preimage wherever a miner has no name: both identify it exactly, and
/// only this one is a string its owner recognises.
pub address: Option<String>,
/// What to render: a telemetry node name when one is attributed, else the
/// abbreviated preimage.
pub display: String,
@@ -142,6 +147,9 @@ pub struct MinerDetail {
pub chain: ChainId,
/// The miner.
pub miner: MinerId,
/// The address it is paid at, derived from the preimage by the same
/// function the chain uses to decide who to pay.
pub address: Option<String>,
/// Current leaderboard row, absent if the miner authored nothing in the
/// window (a small miner between blocks, or one that has stopped).
pub current: Option<LeaderboardRow>,
+7
View File
@@ -14,6 +14,13 @@ rank: number,
* The miner's reward preimage — the stable key for this row.
*/
miner: MinerId,
/**
* The address this miner is paid at, derived from the preimage by the same
* function the chain uses to decide who to pay. Shown in place of the
* preimage wherever a miner has no name: both identify it exactly, and
* only this one is a string its owner recognises.
*/
address: string | null,
/**
* What to render: a telemetry node name when one is attributed, else the
* abbreviated preimage.
+5
View File
@@ -17,6 +17,11 @@ chain: ChainId,
* The miner.
*/
miner: MinerId,
/**
* The address it is paid at, derived from the preimage by the same
* function the chain uses to decide who to pay.
*/
address: string | null,
/**
* Current leaderboard row, absent if the miner authored nothing in the
* window (a small miner between blocks, or one that has stopped).
+7
View File
@@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AttributionSource } from "./AttributionSource";
import type { MinerId } from "./MinerId";
/**
@@ -22,6 +23,12 @@ miner: MinerId,
* Display name for the author at the time the block was pushed.
*/
display: string,
/**
* Where `display` came from. Carried because the ticker cannot tell a node
* name from a derived reward address by looking at the string — several
* real node names begin with the same letter Quantus addresses do.
*/
attribution: AttributionSource,
/**
* When this observer first saw it.
*/
+5 -2
View File
@@ -10,7 +10,7 @@
import { useEffect, useRef, useState } from 'react'
import type { RecentBlock } from '../api/generated/RecentBlock'
import { ago, height, seconds, shortMiner } from '../lib/format'
import { ago, height, seconds } from '../lib/format'
import { usePinnedMiners } from '../lib/store'
export function BlockTicker({
@@ -51,7 +51,10 @@ export function BlockTicker({
<div>
{blocks.map((block) => {
const mine = isPinned(block.miner)
const named = block.display !== shortMiner(block.miner)
// From the backend, not inferred from the string: several real node
// names begin with the same letter a Quantus address does, so
// `quanpool-payout-mainnet` would read as an address.
const named = block.attribution !== 'preimage'
return (
<div
key={block.height}
+9 -3
View File
@@ -12,7 +12,7 @@
*/
import type { LeaderboardRow } from '../api/generated/LeaderboardRow'
import { ago, hashrate, share, shortMiner } from '../lib/format'
import { ago, hashrate, share } from '../lib/format'
import { usePinnedMiners } from '../lib/store'
function Row({
@@ -53,13 +53,19 @@ function Row({
<a
href={`#/miner/${row.miner}`}
className={named || carried ? 'miner-name' : 'miner-name anonymous'}
title={row.miner}
// The address first: it is the identifier the miner recognises. The
// preimage follows because it is what the header actually carries
// and the only one the chain asserts.
title={row.address ? `${row.address}\n${row.miner}` : row.miner}
onClick={(e) => {
e.preventDefault()
onSelect(row.miner)
}}
>
{named || carried ? row.display : shortMiner(row.miner)}
{/* `display` already carries the abbreviated reward address when
no name is known — the backend derives it, because the preimage
is exact and unrecognisable. */}
{row.display}
</a>
{mine && <span className="chip chip-you">You</span>}
{carried && (
+16 -11
View File
@@ -58,17 +58,22 @@ export function MinerPanel({
<h2 className="panel-title" style={{ marginTop: 4 }}>
{named ? row.display : shortMiner(miner)}
</h2>
<div
className="numeral"
style={{
fontSize: 11,
color: 'var(--text-muted)',
marginTop: 4,
overflowWrap: 'anywhere',
}}
>
{miner}
</div>
{/* All three identifiers, because they answer different questions.
The node name is an inference and may be absent; the address is
what the miner configured and recognises; the preimage is what the
block header actually carries and the only one the chain asserts.
A miner checking "is this me?" needs the address; anyone checking
our working needs the preimage. */}
<dl className="identifiers">
{detail?.address && (
<>
<dt>Rewards</dt>
<dd className="numeral">{detail.address}</dd>
</>
)}
<dt>Preimage</dt>
<dd className="numeral">{miner}</dd>
</dl>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<button
+25
View File
@@ -469,6 +469,31 @@ a {
stroke-width: 1;
}
/* The miner's identifiers, stacked label-over-value. A definition list because
that is what it is: terms and their values, which also gives a screen reader
the pairing without any ARIA. */
.identifiers {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px 12px;
margin: 8px 0 0;
font-size: 11px;
}
.identifiers dt {
color: var(--text-muted);
letter-spacing: 0.1em;
text-transform: uppercase;
font-size: 9px;
align-self: center;
}
.identifiers dd {
margin: 0;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
/* ---- block facts --------------------------------------------------------- */
/* The same grid as the stat tiles: this is the same kind of content — a handful