diff --git a/.sqlx/query-089d2d81d7a50482dd988c5807cee6981dd854ef9984f280c3b644ded2a7fb4f.json b/.sqlx/query-089d2d81d7a50482dd988c5807cee6981dd854ef9984f280c3b644ded2a7fb4f.json new file mode 100644 index 0000000..803c7d6 --- /dev/null +++ b/.sqlx/query-089d2d81d7a50482dd988c5807cee6981dd854ef9984f280c3b644ded2a7fb4f.json @@ -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" +} diff --git a/.sqlx/query-41f694ddea9ed92237677559e524a8f8c0e28d31217a80094b5d096214dc4e37.json b/.sqlx/query-41f694ddea9ed92237677559e524a8f8c0e28d31217a80094b5d096214dc4e37.json deleted file mode 100644 index ff05b95..0000000 --- a/.sqlx/query-41f694ddea9ed92237677559e524a8f8c0e28d31217a80094b5d096214dc4e37.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-461853e4ec42eaf95c83f6ed8e177ffd527f1b59461c1e4fa582f1d935c7b6a3.json b/.sqlx/query-461853e4ec42eaf95c83f6ed8e177ffd527f1b59461c1e4fa582f1d935c7b6a3.json deleted file mode 100644 index 888482c..0000000 --- a/.sqlx/query-461853e4ec42eaf95c83f6ed8e177ffd527f1b59461c1e4fa582f1d935c7b6a3.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-613fcaadc85b9d3870abd6d158661b90e4133110744c383e8a20eee87cce5556.json b/.sqlx/query-613fcaadc85b9d3870abd6d158661b90e4133110744c383e8a20eee87cce5556.json new file mode 100644 index 0000000..5264ec1 --- /dev/null +++ b/.sqlx/query-613fcaadc85b9d3870abd6d158661b90e4133110744c383e8a20eee87cce5556.json @@ -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" +} diff --git a/.sqlx/query-594f0b138422b4f15986e92d07deadaac1dce80502a2f8b3e35d490e8ff148d5.json b/.sqlx/query-ecadeb2897735698673b754462d9b537048bf4fa503ae8df878e2681393e0ab7.json similarity index 58% rename from .sqlx/query-594f0b138422b4f15986e92d07deadaac1dce80502a2f8b3e35d490e8ff148d5.json rename to .sqlx/query-ecadeb2897735698673b754462d9b537048bf4fa503ae8df878e2681393e0ab7.json index 3c73c17..77c2386 100644 --- a/.sqlx/query-594f0b138422b4f15986e92d07deadaac1dce80502a2f8b3e35d490e8ff148d5.json +++ b/.sqlx/query-ecadeb2897735698673b754462d9b537048bf4fa503ae8df878e2681393e0ab7.json @@ -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" } diff --git a/CLAUDE.md b/CLAUDE.md index 965efd0..22c2832 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/crates/blackbeard-api/src/ingest.rs b/crates/blackbeard-api/src/ingest.rs index ec3f76c..6764a6f 100644 --- a/crates/blackbeard-api/src/ingest.rs +++ b/crates/blackbeard-api/src/ingest.rs @@ -301,6 +301,7 @@ async fn fill_gap(chain: &Arc, store: &Store, from: u64, to: u64) None } }; + let magnitude: Option = 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, 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 = 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, ); diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index af378af..6285174 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -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(); diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index a59a5c7..d5f486a 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -326,11 +326,12 @@ impl ChainRuntime { /// rather than an identical table every few seconds. pub fn recompute_leaderboard(&self, window: Window) -> (Vec, 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(), diff --git a/crates/blackbeard-core/src/hashrate.rs b/crates/blackbeard-core/src/hashrate.rs index 7f45e3d..9a59269 100644 --- a/crates/blackbeard-core/src/hashrate.rs +++ b/crates/blackbeard-core/src/hashrate.rs @@ -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 { - 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 { + 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" + ); } } diff --git a/crates/blackbeard-core/src/window.rs b/crates/blackbeard-core/src/window.rs index a512dab..fe673f9 100644 --- a/crates/blackbeard-core/src/window.rs +++ b/crates/blackbeard-core/src/window.rs @@ -18,6 +18,17 @@ pub struct Observed { pub miner: MinerId, /// When this observer first saw it. pub observed_at: DateTime, + /// 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>, + /// 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, } /// 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 { + let mut lo: Option> = None; + let mut hi: Option> = None; + for b in self.tail(n) { + let Some(at) = b.authored_at else { continue }; + lo = Some(lo.map_or(at, |c: DateTime| c.min(at))); + hi = Some(hi.map_or(at, |c: DateTime| 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, 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( tallies: &HashMap, window_total: u32, - network_hashrate: Option, + // 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, mut attribute: F, ) -> Vec 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] diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 39c39c5..7ae0417 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -344,9 +344,9 @@ impl Store { ) -> Result, 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, 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, bucket: Duration, - ) -> Result, u32, u32)>, DataError> { + ) -> Result, 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()) } diff --git a/crates/blackbeard-data/tests/store.rs b/crates/blackbeard-data/tests/store.rs index fb16837..3b6a5de 100644 --- a/crates/blackbeard-data/tests/store.rs +++ b/crates/blackbeard-data/tests/store.rs @@ -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::(), 6); - assert_eq!(series.iter().map(|(_, _, total)| total).sum::(), 12); + assert_eq!(series.iter().map(|(_, mine, _, _)| mine).sum::(), 6); + assert_eq!(series.iter().map(|(_, _, total, _)| total).sum::(), 12); // Buckets are contiguous and ascending. for pair in series.windows(2) { assert!(pair[1].0 > pair[0].0); diff --git a/web/src/App.tsx b/web/src/App.tsx index 91e6332..679f590 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -240,8 +240,10 @@ export default function App() { header — no registration, no opt-in, no way to be left out. - 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. Node names are a best-effort mapping, inferred from which node reported a block to diff --git a/web/src/components/MinerPanel.tsx b/web/src/components/MinerPanel.tsx index 33e7705..8124183 100644 --- a/web/src/components/MinerPanel.tsx +++ b/web/src/components/MinerPanel.tsx @@ -135,7 +135,7 @@ export function MinerPanel({
Est. hashrate
{hashrate(row?.hashrate_estimate)}
-
from share of blocks won
+
from work its blocks cost
Blocks observed