earned-windows-26: the engine's earned windows (24 hours, 7 days, all time) from what the chain paid at the time

The founder's audit (docs/design/app-audit-2026-10-08.md, item 2): the Earnings page reads measured sums, never
today's reward applied to old blocks. The ladder keeps one row per coinbase the node credited and per shard paid
(Ladder.earned_log: at, shard, wei as a decimal string), trimmed to 7 days plus an hour, and the all-time running
totals (earned_all_wei, earned_all_blocks, earned_all_shards). A block row is priced at the miner reward the chain
facts carry when the block lands (chainfacts::cached_reward_wei, the dashboard's 10 s cache) or on the first state read
that has the figure (price_blocks); a priced row never changes. A shard row carries the wei its record paid. The
dashboard reads state.ladder.earned = {h24, d7, all} each {wei, ign, blocks, shards, priced} plus `estimated`:
a record from before the rows is back-filled once (backfill_earned) from the lifetime count the engine persists at
the reward then read, with the kept shard records, and says estimated for all time; the windows never invent rows.

Tests (ladder.rs): three windows over a fixed record at two rewards (the ramp moved between them); a block at the
24 h edge counts once, on the far side; an unpriced block reads priced:false until the reward arrives and then takes
that reward and no later one; the log trims past 7 days while all time keeps the block; the back-fill runs once,
says estimated, and a fresh record with rows for every block is not estimated. The crate: 308 tests green on build-1.
Builds on earnings-26 66cc2041 (blocks_24h); the view reads the windows in app-ia-26 item 5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 14:09:53 +00:00
parent 66cc2041e8
commit 65828fb20e
3 changed files with 245 additions and 2 deletions

View file

@ -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<u128> {
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 {

View file

@ -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));

View file

@ -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<f64>,
/// 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<Earned>,
#[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<DayBlocks>,
pub cards_first: BTreeMap<String, f64>,
pub blocks: Vec<BlockFound>,
@ -163,6 +216,10 @@ pub struct LadderState {
pub first_block_shown: bool,
}
fn parse_wei(s: &str) -> u128 {
s.trim().parse::<u128>().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<u64> {
#[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();