fix: a miner's hashrate is the work it did, not its share of today's network
All checks were successful
deploy / build (push) Successful in 6m31s
deploy / deploy-web (push) Successful in 4s
deploy / deploy-api (push) Successful in 14s

Reported against a Grafana panel showing roughly a sixth of what the site did.
Grafana was closer to the truth.

The estimate was `share of blocks in the window × current network hashrate`.
That is only correct while the network is the same size across the whole
window. Measured on mainnet's launch day it was nowhere near: difficulty rose
346-fold inside one 14,400-block window, from 96.4B to 33.4T. A miner that won
its blocks early — when a block cost a three-hundredth of what it costs now —
had that share multiplied by today's network. For this observer's own node the
site reported 25.4 GH/s against an actual 2.5 GH/s.

Difficulty is expected hashes per block by definition, so the difficulty of the
blocks a miner won, summed and divided by the time they spanned, is its
hashrate directly — with no reference to the network's size, and immune to that
size changing underneath, because every block carries the difficulty in force
when it was won. The observer has recorded per-block difficulty correctly since
the parent-state fix, so the data was already there.

`miner_series` had the same flaw and is fixed the same way: summed work per
bucket over the bucket's own duration.

The window's duration is taken from `authored_at`, not `observed_at`. A stretch
this process caught up on carries observation times seconds apart for blocks
minutes apart, and dividing real work by that would invent hashrate — the same
trap the history series was built to avoid.

The headline network figure is unchanged. It divides current difficulty by the
measured interval because it answers a different question: how hard is a block
to win right now, not what did this miner do over the last twelve hours. The
two no longer sum to each other, which is correct and is what the footer now
says.

`standings_rank_by_blocks_then_recency` asserted 400.0 from the old formula;
its third argument is now the window's duration, so it asserts the work-based
figure.

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:45:34 +03:00
parent 17ad5b1f74
commit 9eb4bee649
15 changed files with 295 additions and 117 deletions

View File

@@ -0,0 +1,43 @@
{
"db_name": "PostgreSQL",
"query": "\n select date_bin($3, observed_at, timestamptz 'epoch') as \"bucket!\",\n count(*) filter (where miner = $2) as \"mine!\",\n count(*) as \"total!\",\n -- Expected hashes this miner spent in the bucket. Over the\n -- bucket's own duration that is its hashrate directly, with\n -- no reference to the network's size — which is what makes\n -- the series survive difficulty moving underneath it.\n coalesce(sum(difficulty) filter (where miner = $2), 0)::text as \"work!\"\n from block\n where chain = $1 and observed_at >= $4\n group by 1\n order by 1 asc\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "bucket!",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "mine!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "total!",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "work!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Interval",
"Timestamptz"
]
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "089d2d81d7a50482dd988c5807cee6981dd854ef9984f280c3b644ded2a7fb4f"
}

View File

@@ -1,37 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n select date_bin($3, observed_at, timestamptz 'epoch') as \"bucket!\",\n count(*) filter (where miner = $2) as \"mine!\",\n count(*) as \"total!\"\n from block\n where chain = $1 and observed_at >= $4\n group by 1\n order by 1 asc\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "bucket!",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "mine!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "total!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Interval",
"Timestamptz"
]
},
"nullable": [
null,
null,
null
]
},
"hash": "41f694ddea9ed92237677559e524a8f8c0e28d31217a80094b5d096214dc4e37"
}

View File

@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n select height, miner, observed_at\n from (\n select height, miner, observed_at\n from block\n where chain = $1\n order by height desc\n limit $2\n ) recent\n order by height asc\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "miner",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "observed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "461853e4ec42eaf95c83f6ed8e177ffd527f1b59461c1e4fa582f1d935c7b6a3"
}

View File

@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "\n select height, miner, observed_at, authored_at, difficulty\n from (\n select height, miner, observed_at, authored_at, difficulty\n from block\n where chain = $1\n order by height desc\n limit $2\n ) recent\n order by height asc\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "miner",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "observed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "authored_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "difficulty",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "613fcaadc85b9d3870abd6d158661b90e4133110744c383e8a20eee87cce5556"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n select height, miner, observed_at, at_tip\n from block\n where chain = $1\n order by height desc\n limit $2\n ",
"query": "\n select height, miner, observed_at, at_tip, authored_at, difficulty\n from block\n where chain = $1\n order by height desc\n limit $2\n ",
"describe": {
"columns": [
{
@@ -22,6 +22,16 @@
"ordinal": 3,
"name": "at_tip",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "authored_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "difficulty",
"type_info": "Numeric"
}
],
"parameters": {
@@ -34,8 +44,10 @@
false,
false,
false,
false
false,
true,
true
]
},
"hash": "594f0b138422b4f15986e92d07deadaac1dce80502a2f8b3e35d490e8ff148d5"
"hash": "ecadeb2897735698673b754462d9b537048bf4fa503ae8df878e2681393e0ab7"
}

View File

@@ -32,6 +32,25 @@ orders of magnitude that still renders as a number. `RollingWindow::push` takes
an explicit `at_tip` flag and backfilled blocks contribute no timing sample. The
`Interval` enum exists so a caller cannot forget to say which kind it has.
**A miner's hashrate is its summed work over time, never its share of the
network.** Difficulty *is* expected hashes per block, so the difficulty of the
blocks a miner won, divided by the time they spanned, is its hashrate directly
— and each block carries the difficulty in force when it was won.
`share of blocks × network hashrate` is only correct while the network is the
same size across the whole window. On mainnet's launch day difficulty rose
**346-fold inside one 14,400-block window**, so a miner that won early — when a
block cost a three-hundredth of what it costs now — had that share multiplied
by today's network and was credited with **ten times** the hashrate it had
(25.4 GH/s reported against 2.5 GH/s actual). Both the leaderboard and
`miner_series` were wrong this way. The headline *network* figure still divides
current difficulty by the measured interval, because that one is asking "how
hard is this to win right now" rather than "what did this miner do".
The window's duration comes from `authored_at`, not `observed_at`: a stretch
this process caught up on carries observation times seconds apart for blocks
minutes apart, and dividing real work by that invents hashrate.
**Windows are block counts, never durations.** See `readme.md`. Anything that
turns `Window` into a time range is wrong.

View File

@@ -301,6 +301,7 @@ async fn fill_gap(chain: &Arc<ChainRuntime>, store: &Store, from: u64, to: u64)
None
}
};
let magnitude: Option<f64> = difficulty.as_deref().and_then(|d| d.parse().ok());
let now = Utc::now();
batch.push(BlockRecord {
chain: chain.id(),
@@ -316,6 +317,8 @@ async fn fill_gap(chain: &Arc<ChainRuntime>, store: &Store, from: u64, to: u64)
height,
miner,
observed_at: now,
authored_at,
difficulty: magnitude,
});
}
@@ -376,6 +379,8 @@ async fn record(
}
};
let magnitude: Option<f64> = difficulty.as_deref().and_then(|d| d.parse().ok());
if let Err(e) = store
.record_blocks(&[BlockRecord {
chain: chain.id(),
@@ -404,6 +409,8 @@ async fn record(
height,
miner: miner.clone(),
observed_at,
authored_at,
difficulty: magnitude,
},
true,
);

View File

@@ -516,14 +516,13 @@ async fn miner(
let (span, bucket) = bucketing(window);
let since = chrono::Utc::now()
- chrono::Duration::from_std(span).unwrap_or_else(|_| chrono::Duration::days(7));
let network_hashrate = runtime.summary().network_hashrate;
let series = state
.store
.miner_series(&runtime.id(), &miner, since, bucket)
.await
.map_err(database_unavailable)?
.into_iter()
.map(|(at, mine, total)| MinerSeriesPoint {
.map(|(at, mine, total, work)| MinerSeriesPoint {
at,
blocks: mine,
share: if total == 0 {
@@ -531,8 +530,14 @@ async fn miner(
} else {
f64::from(mine) / f64::from(total)
},
hashrate_estimate: network_hashrate
.and_then(|n| blackbeard_core::hashrate::miner_hashrate(mine, total, n)),
// Work over the bucket's own duration. The share beside it is still
// a share; this is no longer derived from it, so a bucket mined
// when the network was a third its present size reports what the
// miner did then rather than what that share would be worth now.
hashrate_estimate: blackbeard_core::hashrate::miner_hashrate(
work,
bucket.as_secs_f64(),
),
})
.collect();

View File

@@ -326,11 +326,12 @@ impl ChainRuntime {
/// rather than an identical table every few seconds.
pub fn recompute_leaderboard(&self, window: Window) -> (Vec<LeaderboardRow>, bool) {
let mut inner = self.write();
let interval = self.interval(&inner);
let network = inner
.difficulty
.map(|d| hashrate::network_hashrate(d, interval));
let (tallies, total) = inner.window.tally(window.blocks() as usize);
// The window's own duration, by the chain's clock. Every per-miner
// hashrate is its summed work over this — not its share of the current
// network, which credits a miner that won while the network was small
// with a share of what the network is now.
let span = inner.window.span_seconds(window.blocks() as usize);
// Split the borrow: the tally is done, and `leaderboard` needs a mutable
// attributor while it walks the rows.
@@ -340,7 +341,7 @@ impl ChainRuntime {
..
} = &mut *inner;
let telemetry = &self.telemetry;
let rows = blackbeard_core::window::leaderboard(&tallies, total, network, |miner| {
let rows = blackbeard_core::window::leaderboard(&tallies, total, span, |miner| {
let a = attributor.attribute(miner, |key| telemetry.name_of(key));
(a.display, a.address, a.source, a.confidence, a.votes)
});
@@ -489,6 +490,8 @@ mod tests {
for h in 1..=20u64 {
inner.window.push(
blackbeard_core::window::Observed {
authored_at: None,
difficulty: None,
height: h,
miner: blackbeard_entities::MinerId(format!("0x{:064x}", 1)),
observed_at: Utc::now(),
@@ -512,6 +515,8 @@ mod tests {
for h in 1..=10u64 {
inner.window.push(
blackbeard_core::window::Observed {
authored_at: None,
difficulty: None,
height: h,
miner: blackbeard_entities::MinerId(format!("0x{:064x}", h % 3)),
observed_at: Utc::now(),
@@ -539,6 +544,8 @@ mod tests {
let mut inner = c.write();
inner.window.push(
blackbeard_core::window::Observed {
authored_at: None,
difficulty: None,
height: 1,
miner: blackbeard_entities::MinerId(format!("0x{:064x}", 1)),
observed_at: Utc::now(),
@@ -554,6 +561,8 @@ mod tests {
let mut inner = c.write();
inner.window.push(
blackbeard_core::window::Observed {
authored_at: None,
difficulty: None,
height: 2,
miner: blackbeard_entities::MinerId(format!("0x{:064x}", 2)),
observed_at: Utc::now(),

View File

@@ -72,18 +72,30 @@ impl Interval {
}
}
/// A miner's implied hashrate: its share of blocks times the network's.
/// A miner's hashrate, from the work its blocks actually cost.
///
/// This is a statistical estimate over a finite window, not a measurement of
/// anyone's hardware. Winning blocks is a Poisson process, so a miner holding
/// 1% of the network hashrate will, over a 600-block window, quite ordinarily
/// show anywhere from 2 to 11 blocks. Short windows flatter the lucky and libel
/// the unlucky; the UI should show the window length beside the figure.
pub fn miner_hashrate(blocks_in_window: u32, window_total: u32, network: f64) -> Option<f64> {
if window_total == 0 {
/// Difficulty **is** expected hashes per block, so the difficulty of every
/// block a miner won, summed and divided by the time those blocks spanned, is
/// its hashrate directly. No reference to the network's total is needed and
/// none is wanted.
///
/// This replaced `share of blocks × network hashrate`, which is only correct
/// while the network is the same size across the whole window. Measured on
/// mainnet's launch day it was not: difficulty rose **346-fold** over one
/// 14,400-block window, so a miner who won early — when a block cost a three
/// hundredth of what it costs now — had that share multiplied by today's
/// network and was credited with ten times the hashrate it had. Summed work
/// cannot make that mistake, because each block carries the difficulty in force
/// when it was won.
///
/// It remains an estimate over a finite window: winning blocks is a Poisson
/// process, and a miner that won two blocks instead of its expected one reads
/// twice as fast. What it no longer is, is systematically wrong.
pub fn miner_hashrate(work: f64, window_seconds: f64) -> Option<f64> {
if window_seconds <= 0.0 || !work.is_finite() || work <= 0.0 {
return None;
}
Some(network * f64::from(blocks_in_window) / f64::from(window_total))
Some(work / window_seconds)
}
#[cfg(test)]
@@ -129,11 +141,30 @@ mod tests {
}
#[test]
fn miner_share_scales_the_network_figure() {
assert_eq!(miner_hashrate(25, 100, 400.0), Some(100.0));
assert_eq!(miner_hashrate(0, 100, 400.0), Some(0.0));
// An empty window has no share to compute, and 0/0 must not become NaN
// on the dashboard.
assert_eq!(miner_hashrate(5, 0, 400.0), None);
fn a_miners_hashrate_is_the_work_its_blocks_cost() {
// Two blocks of 1.2e9 expected hashes each, found over 12 seconds.
assert_eq!(miner_hashrate(2.4e9, 12.0), Some(2.0e8));
}
#[test]
fn work_over_no_time_is_not_a_hashrate() {
// A window that spans nothing divides by zero; an infinite hashrate on
// the leaderboard is worse than an absent one.
assert_eq!(miner_hashrate(1.0e9, 0.0), None);
assert_eq!(miner_hashrate(0.0, 60.0), None);
}
#[test]
fn a_rising_network_no_longer_inflates_an_early_miner() {
// The mainnet-launch case. A miner won all its blocks while a block
// cost 1e11; the network now needs 3.46e13. Share-based estimation
// multiplied its whole-window share by today's figure; work-based
// reports what it actually did, whatever the network has since become.
let early_work = 5.0 * 1.0e11;
let over_an_hour = miner_hashrate(early_work, 3600.0).unwrap();
assert!(
(over_an_hour - 1.388e8).abs() < 1.0e6,
"{over_an_hour} should reflect the work, not the current network"
);
}
}

View File

@@ -18,6 +18,17 @@ pub struct Observed {
pub miner: MinerId,
/// When this observer first saw it.
pub observed_at: DateTime<Utc>,
/// The author's own timestamp. The chain's clock rather than ours, so a
/// window's duration is what the chain did and not when we happened to
/// look — the same reason the history series times itself this way.
pub authored_at: Option<DateTime<Utc>>,
/// Expected hashes to win this block, as a magnitude.
///
/// `f64` rather than the exact U512: this is summed into a hashrate whose
/// error is dominated by how few blocks a small miner wins, not by the last
/// significant bit of a divisor. The exact value still crosses every
/// boundary as a decimal string; nothing here rounds what is displayed.
pub difficulty: Option<f64>,
}
/// Per-miner totals over a window.
@@ -25,6 +36,14 @@ pub struct Observed {
pub struct MinerTally {
/// Blocks authored in the window.
pub blocks: u32,
/// Expected hashes to win them, summed.
///
/// This, over the window's duration, *is* the miner's hashrate — difficulty
/// is expected hashes per block by definition. It is the estimator that
/// survives the network changing size mid-window, because every block
/// carries the difficulty in force when it was won rather than borrowing
/// today's.
pub work: f64,
/// Highest block this miner authored in the window.
pub last_block_height: u64,
/// When that block was observed.
@@ -127,6 +146,25 @@ impl RollingWindow {
self.blocks.iter().skip(self.blocks.len().saturating_sub(n))
}
/// Seconds the last `n` blocks actually spanned, by the chain's clock.
///
/// The denominator of every per-miner hashrate. Taken from `authored_at`
/// rather than `observed_at` because a stretch this process caught up on
/// carries observation times seconds apart for blocks minutes apart, and
/// dividing real work by that invents hashrate. `None` when too few blocks
/// carry a timestamp to span anything.
pub fn span_seconds(&self, n: usize) -> Option<f64> {
let mut lo: Option<DateTime<Utc>> = None;
let mut hi: Option<DateTime<Utc>> = None;
for b in self.tail(n) {
let Some(at) = b.authored_at else { continue };
lo = Some(lo.map_or(at, |c: DateTime<Utc>| c.min(at)));
hi = Some(hi.map_or(at, |c: DateTime<Utc>| c.max(at)));
}
let seconds = (hi? - lo?).as_seconds_f64();
(seconds > 0.0).then_some(seconds)
}
/// Per-miner totals over the last `n` blocks, plus how many blocks that
/// actually was (fewer than `n` before the window has filled).
pub fn tally(&self, n: usize) -> (HashMap<MinerId, MinerTally>, u32) {
@@ -142,11 +180,13 @@ impl RollingWindow {
total += 1;
let entry = tallies.entry(b.miner.clone()).or_insert(MinerTally {
blocks: 0,
work: 0.0,
last_block_height: b.height,
last_seen: b.observed_at,
best_streak: 0,
});
entry.blocks += 1;
entry.work += b.difficulty.unwrap_or(0.0);
if b.height >= entry.last_block_height {
entry.last_block_height = b.height;
entry.last_seen = b.observed_at;
@@ -181,7 +221,9 @@ impl RollingWindow {
pub fn leaderboard<F>(
tallies: &HashMap<MinerId, MinerTally>,
window_total: u32,
network_hashrate: Option<f64>,
// How long the window spanned, by the chain's clock. The denominator of
// every hashrate here; `None` leaves them all absent rather than guessed.
window_seconds: Option<f64>,
mut attribute: F,
) -> Vec<LeaderboardRow>
where
@@ -217,8 +259,8 @@ where
} else {
f64::from(tally.blocks) / f64::from(window_total)
},
hashrate_estimate: network_hashrate
.and_then(|n| crate::hashrate::miner_hashrate(tally.blocks, window_total, n)),
hashrate_estimate: window_seconds
.and_then(|secs| crate::hashrate::miner_hashrate(tally.work, secs)),
last_block_height: tally.last_block_height,
last_seen: tally.last_seen,
best_streak: tally.best_streak,
@@ -284,6 +326,10 @@ mod tests {
fn obs(height: u64, m: u8, secs: i64) -> Observed {
Observed {
authored_at: Some(at(secs)),
// A constant cost per block, so a tally's work is proportional to
// its block count and the tests can reason in blocks.
difficulty: Some(1.0e9),
height,
miner: miner(m),
observed_at: at(secs),
@@ -380,6 +426,9 @@ mod tests {
w.push(obs(h, m, h as i64), true);
}
let (t, total) = w.tally(100);
// The third argument is the window's duration in seconds, not the
// network's hashrate: a row's estimate is now the work its own blocks
// cost, over the time they spanned.
let rows = leaderboard(&t, total, Some(1000.0), |m| {
(m.abbreviated(), None, AttributionSource::Preimage, 0.0, 0)
});
@@ -390,7 +439,8 @@ mod tests {
assert_eq!(rows[2].miner, miner(3));
assert_eq!(rows[0].rank, 1);
assert!((rows[0].share - 0.4).abs() < 1e-9);
assert_eq!(rows[0].hashrate_estimate, Some(400.0));
// Two blocks at 1e9 expected hashes each, over 1000 seconds.
assert_eq!(rows[0].hashrate_estimate, Some(2.0e6));
}
#[test]

View File

@@ -344,9 +344,9 @@ impl Store {
) -> Result<Vec<Observed>, DataError> {
let rows = sqlx::query!(
r#"
select height, miner, observed_at
select height, miner, observed_at, authored_at, difficulty
from (
select height, miner, observed_at
select height, miner, observed_at, authored_at, difficulty
from block
where chain = $1
order by height desc
@@ -366,6 +366,14 @@ impl Store {
height: r.height as u64,
miner: MinerId(r.miner),
observed_at: r.observed_at,
authored_at: r.authored_at,
// `to_string().parse()` rather than a direct conversion:
// difficulty is a `numeric` far past any integer type, and this
// is the one place its magnitude alone is wanted.
difficulty: r
.difficulty
.as_ref()
.and_then(|d| d.to_string().parse().ok()),
})
.collect())
}
@@ -385,7 +393,7 @@ impl Store {
) -> Result<Vec<(Observed, bool)>, DataError> {
let rows = sqlx::query!(
r#"
select height, miner, observed_at, at_tip
select height, miner, observed_at, at_tip, authored_at, difficulty
from block
where chain = $1
order by height desc
@@ -404,6 +412,11 @@ impl Store {
height: r.height as u64,
miner: MinerId(r.miner),
observed_at: r.observed_at,
authored_at: r.authored_at,
difficulty: r
.difficulty
.as_ref()
.and_then(|d| d.to_string().parse().ok()),
},
r.at_tip,
)
@@ -453,7 +466,7 @@ impl Store {
miner: &MinerId,
since: DateTime<Utc>,
bucket: Duration,
) -> Result<Vec<(DateTime<Utc>, u32, u32)>, DataError> {
) -> Result<Vec<(DateTime<Utc>, u32, u32, f64)>, DataError> {
let interval = sqlx::postgres::types::PgInterval {
months: 0,
days: 0,
@@ -461,9 +474,14 @@ impl Store {
};
let rows = sqlx::query!(
r#"
select date_bin($3, observed_at, timestamptz 'epoch') as "bucket!",
count(*) filter (where miner = $2) as "mine!",
count(*) as "total!"
select date_bin($3, observed_at, timestamptz 'epoch') as "bucket!",
count(*) filter (where miner = $2) as "mine!",
count(*) as "total!",
-- Expected hashes this miner spent in the bucket. Over the
-- bucket's own duration that is its hashrate directly, with
-- no reference to the network's size — which is what makes
-- the series survive difficulty moving underneath it.
coalesce(sum(difficulty) filter (where miner = $2), 0)::text as "work!"
from block
where chain = $1 and observed_at >= $4
group by 1
@@ -479,7 +497,14 @@ impl Store {
Ok(rows
.into_iter()
.map(|r| (r.bucket, r.mine as u32, r.total as u32))
.map(|r| {
(
r.bucket,
r.mine as u32,
r.total as u32,
r.work.parse().unwrap_or(0.0),
)
})
.collect())
}

View File

@@ -253,11 +253,11 @@ async fn the_series_buckets_on_fixed_boundaries() {
// about the bucket count, is the one worth making. Two hours of blocks
// starting mid-hour therefore straddle three buckets, not two.
assert!(series.len() >= 2);
for (bucket, _, _) in &series {
for (bucket, _, _, _) in &series {
assert_eq!(bucket.timestamp() % 3600, 0, "buckets start on the hour");
}
assert_eq!(series.iter().map(|(_, mine, _)| mine).sum::<u32>(), 6);
assert_eq!(series.iter().map(|(_, _, total)| total).sum::<u32>(), 12);
assert_eq!(series.iter().map(|(_, mine, _, _)| mine).sum::<u32>(), 6);
assert_eq!(series.iter().map(|(_, _, total, _)| total).sum::<u32>(), 12);
// Buckets are contiguous and ascending.
for pair in series.windows(2) {
assert!(pair[1].0 > pair[0].0);

View File

@@ -240,8 +240,10 @@ export default function App() {
header no registration, no opt-in, no way to be left out.
</span>
<span>
Hashrates are estimates from blocks won over a finite window, not measurements of anyone's
hardware.
Hashrates are estimates over a finite window, not measurements of anyone's hardware. A
miner's figure is the difficulty of the blocks it won expected hashes, by definition
divided by the time they spanned, so a miner that won while the network was smaller is
credited with the work it actually did rather than a share of what the network is now.
</span>
<span>
Node names are a best-effort mapping, inferred from which node reported a block to

View File

@@ -135,7 +135,7 @@ export function MinerPanel({
<div className="stat">
<div className="stat-label">Est. hashrate</div>
<div className="stat-value">{hashrate(row?.hashrate_estimate)}</div>
<div className="stat-note">from share of blocks won</div>
<div className="stat-note">from work its blocks cost</div>
</div>
<div className="stat">
<div className="stat-label">Blocks observed</div>