fix: a miner's hashrate is the work it did, not its share of today's network
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:
43
.sqlx/query-089d2d81d7a50482dd988c5807cee6981dd854ef9984f280c3b644ded2a7fb4f.json
generated
Normal file
43
.sqlx/query-089d2d81d7a50482dd988c5807cee6981dd854ef9984f280c3b644ded2a7fb4f.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
47
.sqlx/query-613fcaadc85b9d3870abd6d158661b90e4133110744c383e8a20eee87cce5556.json
generated
Normal file
47
.sqlx/query-613fcaadc85b9d3870abd6d158661b90e4133110744c383e8a20eee87cce5556.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
19
CLAUDE.md
19
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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user