diff --git a/app/igneum-app/src/chainfacts.rs b/app/igneum-app/src/chainfacts.rs index 3cbc0e1d8..4c65454eb 100644 --- a/app/igneum-app/src/chainfacts.rs +++ b/app/igneum-app/src/chainfacts.rs @@ -136,6 +136,16 @@ pub fn stats_api_from(live_api: &str) -> String { } } +/// earned-windows-26: the miner's reward per block in wei from the cached reply, without a fetch (the dashboard's poll +/// keeps the cache warm while a window is open; `None` when nothing is cached or the figure is missing). The ladder +/// prices a block at this figure when the block lands, so a window sums what the chain paid at the time. +pub fn cached_reward_wei() -> Option { + let c = CACHE.lock().unwrap(); + let (_, v) = c.as_ref()?; + let ign = num(get(v, &["network", "miner_ign_per_block"])); + if ign > 0.0 { Some((ign * 1e18).round() as u128) } else { None } +} + /// The reply for the dashboard, cached 10 s: the node first, the observer second, `{ok:false}` with the reason when /// neither answers. pub fn fetch(evm_port: u16, live_api: &str, ids: &[String]) -> Value { diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 836469efc..e040fc67a 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -293,7 +293,17 @@ impl Shared { st.uptime_s = self.started.elapsed().as_secs(); st.started_at = self.started_unix; st.events = self.rings.lock().unwrap().events.iter().cloned().collect(); - st.ladder = self.ladder.lock().unwrap().state(st.now, LADDER_GAP_S); + st.ladder = { + let mut ld = self.ladder.lock().unwrap(); + // earned-windows-26: a block that landed while the reward was unread is priced on the first read that has it + if let Some(wei) = crate::chainfacts::cached_reward_wei() { + let lifetime = self.settings.lock().unwrap().accepted_total; + let filled = ld.backfill_earned(lifetime, wei); + let priced = ld.price_blocks(wei); + if filled || priced > 0 { ld.save(&self.ladder_path); } + } + ld.state(st.now, LADDER_GAP_S) + }; match serde_json::to_value(st) { Ok(v) => v, Err(e) => { @@ -5321,7 +5331,17 @@ impl Engine { self.shared.event("block", &format!("block accepted by the node ({card_name})")); // miner-ui-5: the machine's own ladder record (the block card, the days ring); the hash joins by nonce let nonce = crate::ladder::parse_miner_accepted(text).unwrap_or_default(); - let milestone = self.shared.ladder.lock().unwrap().on_block(unix, &card_key, &card_name, &nonce, total); + let milestone = { + let mut ld = self.shared.ladder.lock().unwrap(); + let m = ld.on_block(unix, &card_key, &card_name, &nonce, total); + // earned-windows-26: the block is priced at the reward the chain facts carry now (the cache), or on the + // next state read that has the figure; the back-fill of an older record runs once, here or there + if let Some(wei) = crate::chainfacts::cached_reward_wei() { + ld.backfill_earned(total, wei); + ld.price_blocks(wei); + } + m + }; self.shared.save_ladder(); if let Some(m) = milestone { self.shared.event("block", &crate::ladder::milestone_event(&m)); diff --git a/app/igneum-app/src/ladder.rs b/app/igneum-app/src/ladder.rs index cb39602c1..3514ee00f 100644 --- a/app/igneum-app/src/ladder.rs +++ b/app/igneum-app/src/ladder.rs @@ -49,6 +49,43 @@ pub struct ShardPaid { pub at: f64, } +/// earned-windows-26 (the founder's audit, 8 October 2026, item 2): one row per coinbase the node credited and per shard +/// paid, with the wei it paid at the time. `wei` is empty until the block is priced (the chain facts' reward at the +/// block's time, read from the engine's cache when the block lands or on the next read that has it); a priced row +/// never changes, so a window is the sum of what the chain paid, never today's reward applied to old blocks. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct Earned { + pub at: f64, + #[serde(default)] + pub shard: bool, + #[serde(default)] + pub wei: String, +} + +/// One earned window for the dashboard: the wei as a decimal string, the IGN as a number for the view, the counts, +/// and whether every row in it is priced (`priced` false while a block waits for its reward figure). +#[derive(Clone, Debug, Default, Serialize, PartialEq)] +pub struct Window { + pub wei: String, + pub ign: f64, + pub blocks: u64, + pub shards: u64, + pub priced: bool, +} + +#[derive(Clone, Debug, Default, Serialize, PartialEq)] +pub struct EarnedWindows { + pub h24: Window, + pub d7: Window, + pub all: Window, + /// the all-time figure was back-filled once from the lifetime count at one reward (a record from before the rows) + pub estimated: bool, +} + +/// the earned log keeps 7 days plus an hour of rows; the all-time window is a running total +const KEEP_EARNED_S: f64 = 7.0 * DAY_S + 3600.0; +const WEI_PER_IGN: f64 = 1e18; + #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct Milestone { /// first | hundred | thousand | ten_thousand | card @@ -83,6 +120,20 @@ pub struct Ladder { /// Trimmed to the day on every read; a record from before the field reads its times from `blocks` once. #[serde(default)] pub block_times: Vec, + /// earned-windows-26: the rows of the last 7 days and the all-time running totals (wei as a decimal string) + #[serde(default)] + pub earned_log: Vec, + #[serde(default)] + pub earned_all_wei: String, + #[serde(default)] + pub earned_all_blocks: u64, + #[serde(default)] + pub earned_all_shards: u64, + /// the one-time back-fill of a record from before the rows (the lifetime count at the reward then read) + #[serde(default)] + pub earned_backfilled: bool, + #[serde(default)] + pub earned_estimated: bool, #[serde(default)] pub votes_signed: u64, #[serde(default)] @@ -145,6 +196,8 @@ pub struct LadderState { pub blocks_today: u64, /// blocks the node credited to this address in the last 24 hours (the coinbase reads), never a projection pub blocks_24h: u64, + /// earned-windows-26: what the chain paid this machine in the last 24 hours, 7 days and all time + pub earned: EarnedWindows, pub days: Vec, pub cards_first: BTreeMap, pub blocks: Vec, @@ -163,6 +216,10 @@ pub struct LadderState { pub first_block_shown: bool, } +fn parse_wei(s: &str) -> u128 { + s.trim().parse::().unwrap_or(0) +} + pub fn day_of(unix: f64) -> u64 { (unix.max(0.0) / DAY_S).floor() as u64 } @@ -293,6 +350,9 @@ impl Ladder { self.blocks.truncate(KEEP_BLOCKS); self.block_times.push(now); self.block_times.retain(|t| *t > now - DAY_S - 3600.0); + self.earned_log.push(Earned { at: now, shard: false, wei: String::new() }); + self.earned_all_blocks += 1; + self.trim_earned(now); let kind = if first { "first" } else if total == 100 { "hundred" } else if total == 1_000 { "thousand" } else if total >= 10_000 && total % 10_000 == 0 { "ten_thousand" } else if new_card && total > 1 { "card" } else { "" }; if kind.is_empty() { return None; @@ -349,6 +409,81 @@ impl Ladder { } self.shards.insert(0, ShardPaid { block, shard, wei: wei.to_string(), at: now }); self.shards.truncate(KEEP_SHARDS); + self.earned_log.push(Earned { at: now, shard: true, wei: wei.to_string() }); + self.earned_all_shards += 1; + self.earned_all_wei = (parse_wei(&self.earned_all_wei) + wei).to_string(); + self.trim_earned(now); + } + + fn trim_earned(&mut self, now: f64) { + self.earned_log.retain(|e| e.at > now - KEEP_EARNED_S); + } + + /// Price every block row that has no wei yet at `reward_wei` (the chain facts' miner reward when the engine reads it; + /// a block lands and is priced within the same minute when the cache is warm). Returns how many rows it priced. + pub fn price_blocks(&mut self, reward_wei: u128) -> usize { + if reward_wei == 0 { + return 0; + } + let mut n = 0usize; + let mut add: u128 = 0; + for e in self.earned_log.iter_mut() { + if !e.shard && e.wei.is_empty() { + e.wei = reward_wei.to_string(); + add += reward_wei; + n += 1; + } + } + if n > 0 { + self.earned_all_wei = (parse_wei(&self.earned_all_wei) + add).to_string(); + } + n + } + + /// A record from before the rows (0.3.25 and earlier): the all-time window starts from the lifetime count the engine + /// persists (settings.json accepted_total) at the reward read now, once; the shards kept in the list join it. The + /// windows then say `estimated` for all time until the counts are all from rows. + pub fn backfill_earned(&mut self, lifetime_blocks: u64, reward_wei: u128) -> bool { + if self.earned_backfilled || reward_wei == 0 { + return false; + } + self.earned_backfilled = true; + if lifetime_blocks > self.earned_all_blocks { + let missing = lifetime_blocks - self.earned_all_blocks; + self.earned_all_blocks = lifetime_blocks; + self.earned_all_wei = (parse_wei(&self.earned_all_wei) + reward_wei * missing as u128).to_string(); + self.earned_estimated = true; + } + let logged_shards = self.earned_log.iter().filter(|e| e.shard).count() as u64; + if self.shards.len() as u64 > logged_shards { + let extra: Vec<&ShardPaid> = self.shards.iter().skip(logged_shards as usize).collect(); + let wei: u128 = extra.iter().map(|s| parse_wei(&s.wei)).sum(); + self.earned_all_shards += extra.len() as u64; + self.earned_all_wei = (parse_wei(&self.earned_all_wei) + wei).to_string(); + } + true + } + + /// The three windows at `now`: 24 hours and 7 days from the rows, all time from the running totals. + pub fn earned_windows(&self, now: f64) -> EarnedWindows { + let window = |from: f64| -> Window { + let mut w = Window { priced: true, ..Default::default() }; + let mut wei: u128 = 0; + for e in self.earned_log.iter().filter(|e| e.at > from) { + if e.shard { w.shards += 1; } else { w.blocks += 1; } + if e.wei.is_empty() { w.priced = false; } else { wei += parse_wei(&e.wei); } + } + w.wei = wei.to_string(); + w.ign = wei as f64 / WEI_PER_IGN; + w + }; + let all_wei = parse_wei(&self.earned_all_wei); + EarnedWindows { + h24: window(now - DAY_S), + d7: window(now - 7.0 * DAY_S), + all: Window { wei: all_wei.to_string(), ign: all_wei as f64 / WEI_PER_IGN, blocks: self.earned_all_blocks, shards: self.earned_all_shards, priced: !self.earned_log.iter().any(|e| !e.shard && e.wei.is_empty()) }, + estimated: self.earned_estimated, + } } /// Seconds of unbroken signing at `now` (0 when the last vote is older than `gap_s`). @@ -386,6 +521,7 @@ impl Ladder { blocks_30d: self.blocks_since(window_from), blocks_today: self.days.iter().find(|d| d.day == today).map(|d| d.blocks).unwrap_or(0), blocks_24h: self.blocks_24h(now), + earned: self.earned_windows(now), days: self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > window_from).cloned().collect(), cards_first: self.cards_first.clone(), blocks: self.blocks.iter().take(10).cloned().collect(), @@ -455,6 +591,83 @@ pub fn parse_vote_index(text: &str) -> Option { #[cfg(test)] mod tests { + // earned-windows-26: three windows over a fixed record; a block at the 24 h edge counts once; the wei is what the + // chain paid at the time, never today's reward applied to old blocks; the back-fill runs once + #[test] + fn the_earned_windows_sum_what_the_chain_paid_at_the_time_and_the_edge_block_counts_once() { + let mut l = Ladder::default(); + let t0 = 1_791_400_000.0; + let reward_a: u128 = 4_880_000_000_000_000_000; // 4.88 IGN + let reward_b: u128 = 4_000_000_000_000_000_000; // 4.00 IGN, the ramp moved + // eight days ago: a block at reward_a (outside every window but all time) + l.on_block(t0 - 8.0 * DAY_S, "c", "c", "", 1); + assert_eq!(l.price_blocks(reward_a), 1); + // three days ago: two blocks at reward_a and a shard + l.on_block(t0 - 3.0 * DAY_S, "c", "c", "", 2); + l.on_block(t0 - 3.0 * DAY_S + 1.0, "c", "c", "", 3); + assert_eq!(l.price_blocks(reward_a), 2); + l.on_shard_paid(t0 - 3.0 * DAY_S + 2.0, 100, 3, 120_000_000_000_000_000); // 0.12 IGN + // exactly 24 h ago plus a second: inside the day; exactly at the edge: outside (strict) + l.on_block(t0 - DAY_S + 1.0, "c", "c", "", 4); + l.on_block(t0 - DAY_S, "c", "c", "", 5); + assert_eq!(l.price_blocks(reward_b), 2); + // an hour ago: a block not yet priced + l.on_block(t0 - 3600.0, "c", "c", "", 6); + let w = l.earned_windows(t0); + assert_eq!(w.h24.blocks, 2, "the edge block counts once, on the far side"); + assert_eq!(w.h24.shards, 0); + assert_eq!(w.h24.wei, reward_b.to_string()); + assert!(!w.h24.priced, "the last block waits for its reward"); + assert_eq!(w.d7.blocks, 5); + assert_eq!(w.d7.shards, 1); + assert_eq!(w.d7.wei, (2 * reward_a + 2 * reward_b + 120_000_000_000_000_000).to_string()); + assert!((w.d7.ign - 17.88).abs() < 1e-9); + assert_eq!(w.all.blocks, 6); + assert_eq!(w.all.shards, 1); + assert_eq!(w.all.wei, (3 * reward_a + 2 * reward_b + 120_000_000_000_000_000).to_string(), "all time holds the eight-day-old block at its own reward"); + assert!(!w.estimated); + // the late reward prices the waiting block only; priced rows never move + assert_eq!(l.price_blocks(reward_a), 1); + let w2 = l.earned_windows(t0); + assert!(w2.h24.priced); + assert_eq!(w2.h24.wei, (reward_a + reward_b).to_string()); + assert_eq!(l.price_blocks(reward_b), 0); + assert_eq!(l.earned_windows(t0).h24.wei, (reward_a + reward_b).to_string()); + // the log keeps seven days plus an hour: the eight-day block is gone from the rows, kept in all time + assert_eq!(l.earned_log.len(), 6); + assert_eq!(l.state(t0, 600.0).earned.all.blocks, 6); + } + + #[test] + fn a_record_from_before_the_rows_is_back_filled_once_from_the_lifetime_count_and_says_estimated() { + let mut l = Ladder::default(); + let t0 = 1_791_400_000.0; + l.shards.push(ShardPaid { block: 1, shard: 1, wei: "250000000000000000".into(), at: t0 - 100.0 }); + let reward: u128 = 4_880_000_000_000_000_000; + assert!(l.backfill_earned(40, reward)); + let w = l.earned_windows(t0); + assert_eq!(w.all.blocks, 40); + assert_eq!(w.all.shards, 1); + assert_eq!(w.all.wei, (40 * reward + 250_000_000_000_000_000).to_string()); + assert!(w.estimated); + assert_eq!(w.h24.blocks, 0, "the back-fill never invents rows in a window"); + assert!(!l.backfill_earned(41, reward), "once"); + assert_eq!(l.earned_windows(t0).all.blocks, 40); + // a fresh record with rows for every block is not estimated + let mut f = Ladder::default(); + f.on_block(t0, "c", "c", "", 1); + f.price_blocks(reward); + assert!(f.backfill_earned(1, reward)); + let fw = f.earned_windows(t0 + 1.0); + assert!(!fw.estimated); + assert_eq!(fw.all.blocks, 1); + assert_eq!(fw.all.wei, reward.to_string()); + // a reward of zero back-fills nothing and leaves the flag for the next read + let mut z = Ladder::default(); + assert!(!z.backfill_earned(5, 0)); + assert!(!z.earned_backfilled); + } + #[test] fn the_24_hour_count_reads_every_coinbase_of_the_day_past_the_list_cap_and_rolls_off() { let mut l = Ladder::default();