Skip to main content

bee_tui/components/
lottery.rs

1//! S4 — Lottery / redistribution screen (`docs/PLAN.md` § 8.S4).
2//!
3//! Three panes driven by [`crate::watch::HealthSnapshot`] (for
4//! `/redistributionstate` at 2 s cadence) and
5//! [`crate::watch::LotterySnapshot`] (for `/stake` at 30 s):
6//!
7//! 1. **Round timeline** — current round number plus a three-segment
8//!    bar for the commit (0..38) / reveal (38..76) / claim (76..152)
9//!    phases, with the active phase highlighted and a within-phase
10//!    progress bar. Block-of-round comes from `block % 152` per the
11//!    upstream `DefaultBlocksPerRound` constant in
12//!    `bee-go/pkg/storageincentives/agent.go`.
13//! 2. **Anchor summary** — last *won*, *played*, *frozen*, *selected*
14//!    rounds, each with a `Δ` to the current round so operators see
15//!    "last won 4 rounds ago" without doing arithmetic. The PLAN's
16//!    "last 20 rounds with skip reasons" view needs an upstream
17//!    `RoundData[]` port and is deferred.
18//! 3. **Stake card** — staked BZZ vs the minimum needed to play, with
19//!    the "frozen / unhealthy / insufficient funds" reasons that
20//!    Bee's API exposes only in scattered booleans surfaced as a
21//!    single human sentence.
22//!
23//! Render path delegates to the pure [`Lottery::view_for`] function so
24//! `tests/s4_lottery_view.rs` can pin all state derivations
25//! (phase math, skip-reason reconstruction, stake-status ladder)
26//! without a TUI.
27
28use std::sync::Arc;
29
30use color_eyre::Result;
31use crossterm::event::{KeyCode, KeyEvent};
32use num_bigint::BigInt;
33use ratatui::{
34    Frame,
35    layout::{Constraint, Layout, Rect},
36    style::{Color, Modifier, Style},
37    text::{Line, Span},
38    widgets::{Block, Borders, Paragraph},
39};
40use tokio::sync::{mpsc, watch};
41
42use super::Component;
43use super::swap::format_plur;
44use crate::action::Action;
45use crate::api::ApiClient;
46use crate::theme;
47use crate::watch::{HealthSnapshot, LotterySnapshot};
48
49use bee::debug::{RCHashResponse, RedistributionState};
50
51/// `pkg/storageincentives/agent.go:36` — round length in blocks.
52pub const BLOCKS_PER_ROUND: u64 = 152;
53/// One-quarter of the round (152 / 4); commit and reveal each take this many
54/// blocks, claim takes the remaining 76.
55pub const BLOCKS_PER_PHASE: u64 = 38;
56
57/// Discrete phase enum derived from the API's `phase: String`. Falls
58/// back to [`Phase::Unknown`] if Bee returns a value we don't model.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Phase {
61    Commit,
62    Reveal,
63    Claim,
64    /// In-between rounds — the agent is sampling chunks for the next
65    /// commit. Bee surfaces this as `phase: "sample"`.
66    Sample,
67    Unknown,
68}
69
70impl Phase {
71    fn from_api(s: &str) -> Self {
72        match s {
73            "commit" => Self::Commit,
74            "reveal" => Self::Reveal,
75            "claim" => Self::Claim,
76            "sample" => Self::Sample,
77            _ => Self::Unknown,
78        }
79    }
80
81    fn label(self) -> &'static str {
82        match self {
83            Self::Commit => "commit",
84            Self::Reveal => "reveal",
85            Self::Claim => "claim",
86            Self::Sample => "sample",
87            Self::Unknown => "?",
88        }
89    }
90}
91
92/// Tri-state per-phase outcome shown in the timeline ribbon.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum PhaseState {
95    /// Phase already completed in the current round.
96    Done,
97    /// Currently in this phase.
98    Active,
99    /// Phase hasn't started yet.
100    Pending,
101}
102
103/// One segment of the timeline ribbon.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct PhaseSegment {
106    pub phase: Phase,
107    pub state: PhaseState,
108    /// Inclusive lower bound of this phase in block-of-round.
109    pub start_block: u64,
110    /// Exclusive upper bound.
111    pub end_block: u64,
112}
113
114/// Top pane.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct RoundCard {
117    pub round: u64,
118    pub block: u64,
119    /// `block % BLOCKS_PER_ROUND` for quick visual reference.
120    pub block_of_round: u64,
121    /// Current phase as enum + label.
122    pub phase: Phase,
123    pub phase_label: &'static str,
124    pub segments: Vec<PhaseSegment>,
125}
126
127/// One row of the anchor-summary pane.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct AnchorRow {
130    pub label: &'static str,
131    pub round: u64,
132    /// `current_round - round`. `None` if the anchor round is 0
133    /// (never reached) or in the future.
134    pub delta: Option<u64>,
135    /// Human description: `"never"`, `"4 rounds ago"`, etc.
136    pub when: String,
137}
138
139/// Tri-state stake card status.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum StakeStatus {
142    /// `staked == 0` — node can't participate.
143    Unstaked,
144    /// 0 < staked but `has_sufficient_funds == false` — operator funded
145    /// the contract but the wallet doesn't have gas, so the node skips.
146    InsufficientGas,
147    /// Frozen out of redistribution by a previous bad commit.
148    Frozen,
149    /// Worker is unhealthy (storage radius drift, sample failures, etc.).
150    Unhealthy,
151    /// `staked > 0`, `has_sufficient_funds`, not frozen, healthy.
152    Healthy,
153    /// `/stake` failed.
154    Unknown,
155}
156
157impl StakeStatus {
158    fn color(self) -> Color {
159        match self {
160            Self::Unstaked => theme::active().fail,
161            Self::InsufficientGas => theme::active().warn,
162            Self::Frozen => theme::active().fail,
163            Self::Unhealthy => theme::active().warn,
164            Self::Healthy => theme::active().pass,
165            Self::Unknown => theme::active().dim,
166        }
167    }
168    fn label(self) -> &'static str {
169        match self {
170            Self::Unstaked => "✗ unstaked",
171            Self::InsufficientGas => "⚠ low gas",
172            Self::Frozen => "✗ frozen",
173            Self::Unhealthy => "⚠ unhealthy",
174            Self::Healthy => "✓ healthy",
175            Self::Unknown => "? unknown",
176        }
177    }
178}
179
180/// Bottom pane.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct StakeCard {
183    pub status: StakeStatus,
184    /// Pre-formatted `BZZ x.xxxx` or `—`.
185    pub staked: String,
186    pub minimum_gas: String,
187    /// Cumulative reward (`BZZ x.xxxx` or `—`).
188    pub reward: String,
189    pub fees: String,
190    /// Last sample duration in seconds, formatted to one decimal.
191    pub last_sample: Option<String>,
192    /// Tooltip explaining the current state when not Healthy.
193    pub why: Option<String>,
194}
195
196/// Aggregated view fed to both renderer and snapshot tests.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct LotteryView {
199    pub round: Option<RoundCard>,
200    pub anchors: Vec<AnchorRow>,
201    pub stake: StakeCard,
202}
203
204/// Lifecycle of the on-demand rchash benchmark. Operator hits `r` to
205/// run; the component owns the request lifecycle via an internal mpsc
206/// channel so the App's action pipeline doesn't grow a one-off
207/// rchash-result variant.
208#[derive(Debug, Clone, PartialEq)]
209pub enum BenchState {
210    Idle,
211    Running,
212    Done {
213        duration_seconds: f64,
214        /// Reserve commitment hash (8-char prefix is what the screen
215        /// shows; full hash is kept here for the sake of completeness).
216        hash: String,
217    },
218    Failed {
219        error: String,
220    },
221}
222
223/// Fixed anchor pair used for the benchmark. Real-round anchors come
224/// off-chain; for a deterministic local sample we use 0x00…00 vs
225/// 0xff…ff so repeat measurements compare cleanly.
226const BENCH_ANCHOR_LO: &str =
227    "0000000000000000000000000000000000000000000000000000000000000000";
228const BENCH_ANCHOR_HI: &str =
229    "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
230/// Fallback depth when `/status` hasn't reported a `storage_radius`
231/// yet. 8 is a typical mainnet radius, so the sample size is
232/// representative.
233const BENCH_DEFAULT_DEPTH: u8 = 8;
234
235/// Pick a depth for the benchmark that mirrors what the agent would
236/// actually sample at: the node's current `storage_radius`. Falls back
237/// to [`BENCH_DEFAULT_DEPTH`] if `/status` hasn't loaded yet. Status's
238/// `storage_radius` is `i64` because the API can return a sentinel
239/// `-1`; we clamp to `0..=255` for the rchash path.
240pub fn bench_depth(health: &HealthSnapshot) -> u8 {
241    let raw = health
242        .status
243        .as_ref()
244        .map(|s| s.storage_radius)
245        .unwrap_or(-1);
246    if raw <= 0 {
247        BENCH_DEFAULT_DEPTH
248    } else {
249        raw.min(255) as u8
250    }
251}
252
253pub struct Lottery {
254    client: Arc<ApiClient>,
255    health_rx: watch::Receiver<HealthSnapshot>,
256    lottery_rx: watch::Receiver<LotterySnapshot>,
257    health: HealthSnapshot,
258    lottery: LotterySnapshot,
259    bench: BenchState,
260    bench_tx: mpsc::UnboundedSender<Result<RCHashResponse, String>>,
261    bench_rx: mpsc::UnboundedReceiver<Result<RCHashResponse, String>>,
262}
263
264impl Lottery {
265    pub fn new(
266        client: Arc<ApiClient>,
267        health_rx: watch::Receiver<HealthSnapshot>,
268        lottery_rx: watch::Receiver<LotterySnapshot>,
269    ) -> Self {
270        let health = health_rx.borrow().clone();
271        let lottery = lottery_rx.borrow().clone();
272        let (bench_tx, bench_rx) = mpsc::unbounded_channel();
273        Self {
274            client,
275            health_rx,
276            lottery_rx,
277            health,
278            lottery,
279            bench: BenchState::Idle,
280            bench_tx,
281            bench_rx,
282        }
283    }
284
285    fn pull_latest(&mut self) {
286        self.health = self.health_rx.borrow().clone();
287        self.lottery = self.lottery_rx.borrow().clone();
288    }
289
290    /// Drain any benchmark results that arrived since the last tick.
291    fn drain_bench_results(&mut self) {
292        while let Ok(result) = self.bench_rx.try_recv() {
293            self.bench = match result {
294                Ok(resp) => BenchState::Done {
295                    duration_seconds: resp.duration_seconds,
296                    hash: resp.hash,
297                },
298                Err(e) => BenchState::Failed { error: e },
299            };
300        }
301    }
302
303    /// Kick off a benchmark unless one is already in flight. Returns
304    /// `true` if a new request was spawned.
305    fn maybe_start_bench(&mut self) -> bool {
306        if matches!(self.bench, BenchState::Running) {
307            return false;
308        }
309        let depth = bench_depth(&self.health);
310        let client = self.client.clone();
311        let tx = self.bench_tx.clone();
312        tokio::spawn(async move {
313            let res = client
314                .bee()
315                .debug()
316                .r_chash(depth, BENCH_ANCHOR_LO, BENCH_ANCHOR_HI)
317                .await
318                .map_err(|e| e.to_string());
319            let _ = tx.send(res);
320        });
321        self.bench = BenchState::Running;
322        true
323    }
324
325    /// Pure, snapshot-driven view computation. Exposed for snapshot
326    /// tests in `tests/s4_lottery_view.rs`.
327    pub fn view_for(health: &HealthSnapshot, lottery: &LotterySnapshot) -> LotteryView {
328        let round = health.redistribution.as_ref().map(round_card_for);
329        let anchors = health
330            .redistribution
331            .as_ref()
332            .map(anchor_rows_for)
333            .unwrap_or_default();
334        let stake = stake_card_for(health.redistribution.as_ref(), lottery);
335        LotteryView {
336            round,
337            anchors,
338            stake,
339        }
340    }
341}
342
343fn round_card_for(r: &RedistributionState) -> RoundCard {
344    let block_of_round = r.block % BLOCKS_PER_ROUND;
345    let phase = Phase::from_api(&r.phase);
346
347    // Build the three on-chain phase segments. (Sample is between rounds and
348    // doesn't get a segment — when phase==Sample, the ribbon shows all three
349    // segments as Done.)
350    let segments = build_phase_segments(phase, block_of_round);
351
352    RoundCard {
353        round: r.round,
354        block: r.block,
355        block_of_round,
356        phase,
357        phase_label: phase.label(),
358        segments,
359    }
360}
361
362fn build_phase_segments(current: Phase, block_of_round: u64) -> Vec<PhaseSegment> {
363    let phases = [
364        (Phase::Commit, 0u64, BLOCKS_PER_PHASE),
365        (Phase::Reveal, BLOCKS_PER_PHASE, 2 * BLOCKS_PER_PHASE),
366        (Phase::Claim, 2 * BLOCKS_PER_PHASE, BLOCKS_PER_ROUND),
367    ];
368    phases
369        .iter()
370        .map(|&(p, start, end)| PhaseSegment {
371            phase: p,
372            state: phase_state_for(p, current, block_of_round, start, end),
373            start_block: start,
374            end_block: end,
375        })
376        .collect()
377}
378
379fn phase_state_for(
380    seg: Phase,
381    current: Phase,
382    block_of_round: u64,
383    start: u64,
384    end: u64,
385) -> PhaseState {
386    // Trust the API's `phase` field for the active marker, but fall back to
387    // block-range arithmetic when it reports `sample` (between rounds —
388    // every commit/reveal/claim is Done) or `unknown`.
389    if current == seg {
390        return PhaseState::Active;
391    }
392    if matches!(current, Phase::Sample) {
393        return PhaseState::Done;
394    }
395    if block_of_round >= end {
396        PhaseState::Done
397    } else if block_of_round < start {
398        PhaseState::Pending
399    } else {
400        // Block-of-round is inside our segment but the API claims a different
401        // phase — surface as Active anyway, the API is authoritative for the
402        // current marker.
403        PhaseState::Active
404    }
405}
406
407fn anchor_rows_for(r: &RedistributionState) -> Vec<AnchorRow> {
408    let current = r.round;
409    let make = |label: &'static str, anchor: u64| AnchorRow {
410        label,
411        round: anchor,
412        delta: if anchor == 0 || anchor > current {
413            None
414        } else {
415            Some(current - anchor)
416        },
417        when: format_when(current, anchor),
418    };
419    vec![
420        make("last won", r.last_won_round),
421        make("last played", r.last_played_round),
422        make("last selected", r.last_selected_round),
423        make("last frozen", r.last_frozen_round),
424    ]
425}
426
427fn format_when(current: u64, anchor: u64) -> String {
428    if anchor == 0 {
429        return "never".into();
430    }
431    if anchor > current {
432        return format!("round {anchor} (future)");
433    }
434    let delta = current - anchor;
435    match delta {
436        0 => "this round".into(),
437        1 => "last round".into(),
438        n => format!("{n} rounds ago"),
439    }
440}
441
442fn stake_card_for(r: Option<&RedistributionState>, lottery: &LotterySnapshot) -> StakeCard {
443    let zero = BigInt::from(0);
444    let staked_bi = lottery.staked.as_ref();
445    let staked_str = staked_bi.map(format_plur).unwrap_or_else(|| "—".into());
446
447    let (minimum_gas, reward, fees, last_sample, status_inputs) = match r {
448        Some(r) => (
449            r.minimum_gas_funds
450                .as_ref()
451                .map(format_plur)
452                .unwrap_or_else(|| "—".into()),
453            r.reward
454                .as_ref()
455                .map(format_plur)
456                .unwrap_or_else(|| "—".into()),
457            r.fees
458                .as_ref()
459                .map(format_plur)
460                .unwrap_or_else(|| "—".into()),
461            (r.last_sample_duration_seconds > 0.0)
462                .then(|| format!("{:.1}s", r.last_sample_duration_seconds)),
463            Some((
464                r.is_frozen,
465                r.is_healthy,
466                r.has_sufficient_funds,
467                r.is_fully_synced,
468                r.last_frozen_round,
469            )),
470        ),
471        None => (
472            "—".into(),
473            "—".into(),
474            "—".into(),
475            None,
476            None,
477        ),
478    };
479
480    let (status, why) = match (lottery.last_error.as_deref(), staked_bi, status_inputs) {
481        (Some(e), _, _) => (StakeStatus::Unknown, Some(format!("/stake error: {e}"))),
482        (_, None, _) => (StakeStatus::Unknown, Some("/stake not loaded yet".into())),
483        (_, Some(s), Some((frozen, healthy, sufficient, synced, last_frozen)))
484            if s == &zero =>
485        {
486            let _ = (frozen, healthy, sufficient, synced, last_frozen);
487            (
488                StakeStatus::Unstaked,
489                Some("0 BZZ staked — node cannot participate in redistribution.".into()),
490            )
491        }
492        (_, Some(_), Some((true, _, _, _, last_frozen))) => (
493            StakeStatus::Frozen,
494            Some(format!(
495                "frozen out at round {last_frozen}; resumes after the freeze window."
496            )),
497        ),
498        (_, Some(_), Some((_, _, false, _, _))) => (
499            StakeStatus::InsufficientGas,
500            Some("operator wallet has too little native token to play a round.".into()),
501        ),
502        (_, Some(_), Some((_, false, _, _, _))) => (
503            StakeStatus::Unhealthy,
504            Some("redistribution worker reports unhealthy — see Health screen.".into()),
505        ),
506        (_, Some(_), Some((_, _, _, false, _))) => (
507            StakeStatus::Unhealthy,
508            Some("node is not fully synced — sampling will skip until it catches up.".into()),
509        ),
510        (_, Some(_), Some(_)) => (StakeStatus::Healthy, None),
511        // Stake known but no redistribution data yet.
512        (_, Some(s), None) if s == &zero => (
513            StakeStatus::Unstaked,
514            Some("0 BZZ staked — node cannot participate in redistribution.".into()),
515        ),
516        (_, Some(_), None) => (
517            StakeStatus::Unknown,
518            Some("redistribution state not loaded yet".into()),
519        ),
520    };
521
522    StakeCard {
523        status,
524        staked: staked_str,
525        minimum_gas,
526        reward,
527        fees,
528        last_sample,
529        why,
530    }
531}
532
533impl Component for Lottery {
534    fn update(&mut self, action: Action) -> Result<Option<Action>> {
535        if matches!(action, Action::Tick) {
536            self.pull_latest();
537            self.drain_bench_results();
538        }
539        Ok(None)
540    }
541
542    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
543        if matches!(key.code, KeyCode::Char('r')) {
544            self.maybe_start_bench();
545        }
546        Ok(None)
547    }
548
549    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
550        let chunks = Layout::vertical([
551            Constraint::Length(3), // header
552            Constraint::Length(6), // round timeline
553            Constraint::Length(7), // anchors
554            Constraint::Min(0),    // stake card
555            Constraint::Length(1), // footer
556        ])
557        .split(area);
558
559        // Header
560        let header_l1 = Line::from(vec![Span::styled(
561            "LOTTERY / REDISTRIBUTION",
562            Style::default().add_modifier(Modifier::BOLD),
563        )]);
564        let mut header_l2 = Vec::new();
565        let t = theme::active();
566        if let Some(err) = &self.lottery.last_error {
567            let (color, msg) = theme::classify_header_error(err);
568            header_l2.push(Span::styled(msg, Style::default().fg(color)));
569        } else if !self.lottery.is_loaded() {
570            header_l2.push(Span::styled(
571                "loading…",
572                Style::default().fg(t.dim),
573            ));
574        }
575        frame.render_widget(
576            Paragraph::new(vec![header_l1, Line::from(header_l2)])
577                .block(Block::default().borders(Borders::BOTTOM)),
578            chunks[0],
579        );
580
581        let view = Self::view_for(&self.health, &self.lottery);
582
583        // Round timeline
584        let mut round_lines: Vec<Line> = Vec::new();
585        if let Some(rc) = &view.round {
586            round_lines.push(Line::from(vec![
587                Span::styled(
588                    format!("  Round {} ", rc.round),
589                    Style::default().add_modifier(Modifier::BOLD),
590                ),
591                Span::styled(
592                    format!("· phase {} · block-of-round {}/{BLOCKS_PER_ROUND}",
593                        rc.phase_label, rc.block_of_round),
594                    Style::default().fg(t.dim),
595                ),
596            ]));
597            round_lines.push(Line::from(segment_spans(&rc.segments)));
598            round_lines.push(Line::from(progress_bar_spans(rc)));
599        } else {
600            round_lines.push(Line::from(Span::styled(
601                "  (redistribution state not loaded yet)",
602                Style::default()
603                    .fg(t.dim)
604                    .add_modifier(Modifier::ITALIC),
605            )));
606        }
607        frame.render_widget(
608            Paragraph::new(round_lines).block(Block::default().borders(Borders::BOTTOM)),
609            chunks[1],
610        );
611
612        // Anchors
613        let mut anchor_lines: Vec<Line> = vec![Line::from(Span::styled(
614            "  ANCHORS         ROUND       WHEN",
615            Style::default()
616                .fg(t.dim)
617                .add_modifier(Modifier::BOLD),
618        ))];
619        if view.anchors.is_empty() {
620            anchor_lines.push(Line::from(Span::styled(
621                "  (no anchor data)",
622                Style::default()
623                    .fg(t.dim)
624                    .add_modifier(Modifier::ITALIC),
625            )));
626        } else {
627            for a in &view.anchors {
628                let round_str = if a.round == 0 {
629                    "—".to_string()
630                } else {
631                    a.round.to_string()
632                };
633                anchor_lines.push(Line::from(vec![
634                    Span::raw("  "),
635                    Span::styled(
636                        format!("{:<14} ", a.label),
637                        Style::default().add_modifier(Modifier::BOLD),
638                    ),
639                    Span::raw(format!("{round_str:<11} ")),
640                    Span::styled(a.when.clone(), Style::default().fg(t.dim)),
641                ]));
642            }
643        }
644        frame.render_widget(
645            Paragraph::new(anchor_lines).block(Block::default().borders(Borders::BOTTOM)),
646            chunks[2],
647        );
648
649        // Stake card
650        let stake = &view.stake;
651        let mut stake_lines = vec![
652            Line::from(vec![
653                Span::styled(
654                    "  Stake  ",
655                    Style::default().add_modifier(Modifier::BOLD),
656                ),
657                Span::styled(
658                    stake.status.label(),
659                    Style::default()
660                        .fg(stake.status.color())
661                        .add_modifier(Modifier::BOLD),
662                ),
663            ]),
664            Line::from(vec![
665                Span::raw(format!("    staked     {}", stake.staked)),
666                Span::raw("   "),
667                Span::raw(format!("min gas funds  {}", stake.minimum_gas)),
668            ]),
669            Line::from(vec![
670                Span::raw(format!("    reward     {}", stake.reward)),
671                Span::raw("   "),
672                Span::raw(format!("fees           {}", stake.fees)),
673            ]),
674        ];
675        if let Some(sample) = &stake.last_sample {
676            stake_lines.push(Line::from(vec![
677                Span::raw("    last sample "),
678                Span::styled(sample.clone(), Style::default().fg(t.info)),
679                Span::styled(
680                    "   (deadline ≈ 95s for the 38-block commit window)",
681                    Style::default().fg(t.dim),
682                ),
683            ]));
684        }
685        if let Some(why) = &stake.why {
686            stake_lines.push(Line::from(vec![
687                Span::raw("    └─ "),
688                Span::styled(
689                    why.clone(),
690                    Style::default()
691                        .fg(t.dim)
692                        .add_modifier(Modifier::ITALIC),
693                ),
694            ]));
695        }
696        // Bench card: rchash benchmark line. Always rendered so the
697        // operator knows the keybinding exists.
698        let depth = bench_depth(&self.health);
699        stake_lines.push(Line::from(""));
700        stake_lines.push(Line::from(vec![
701            Span::styled(
702                "  rchash bench  ",
703                Style::default().add_modifier(Modifier::BOLD),
704            ),
705            Span::styled(
706                format!("(depth {depth}, deterministic anchors)"),
707                Style::default().fg(t.dim),
708            ),
709        ]));
710        match &self.bench {
711            BenchState::Idle => {
712                stake_lines.push(Line::from(vec![
713                    Span::raw("    "),
714                    Span::styled(
715                        "press 'r' to run a sample",
716                        Style::default().fg(t.dim),
717                    ),
718                ]));
719            }
720            BenchState::Running => {
721                stake_lines.push(Line::from(vec![
722                    Span::raw("    "),
723                    Span::styled(
724                        "running… (this can take seconds-to-minutes on a busy reserve)",
725                        Style::default().fg(t.info),
726                    ),
727                ]));
728            }
729            BenchState::Done {
730                duration_seconds,
731                hash,
732            } => {
733                let prefix: String = hash.chars().take(8).collect();
734                let safe = *duration_seconds < 95.0;
735                let style = if safe {
736                    Style::default().fg(t.pass)
737                } else {
738                    Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
739                };
740                let verdict = if safe {
741                    "safe — fits inside the 95 s commit window"
742                } else {
743                    "OVER 95 s commit window — sampler will time out!"
744                };
745                stake_lines.push(Line::from(vec![
746                    Span::raw("    "),
747                    Span::styled(format!("{duration_seconds:.1}s"), style),
748                    Span::raw(format!("   hash {prefix}…   ")),
749                    Span::styled(verdict, Style::default().fg(t.dim)),
750                ]));
751            }
752            BenchState::Failed { error } => {
753                stake_lines.push(Line::from(vec![
754                    Span::raw("    "),
755                    Span::styled(
756                        format!("error: {error}"),
757                        Style::default().fg(t.fail),
758                    ),
759                ]));
760            }
761        }
762        frame.render_widget(Paragraph::new(stake_lines), chunks[3]);
763
764        // Footer
765        frame.render_widget(
766            Paragraph::new(Line::from(vec![
767                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
768                Span::raw(" switch screen  "),
769                Span::styled(" r ", Style::default().fg(Color::Black).bg(Color::White)),
770                Span::raw(" run rchash benchmark  "),
771                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
772                Span::raw(" quit  "),
773            ])),
774            chunks[4],
775        );
776
777        Ok(())
778    }
779}
780
781fn segment_spans(segs: &[PhaseSegment]) -> Vec<Span<'static>> {
782    let t = theme::active();
783    let mut out = vec![Span::raw("  ")];
784    for (i, s) in segs.iter().enumerate() {
785        let color = match s.state {
786            PhaseState::Done => t.dim,
787            PhaseState::Active => t.warn,
788            PhaseState::Pending => Color::White,
789        };
790        let modifier = if matches!(s.state, PhaseState::Active) {
791            Modifier::BOLD
792        } else {
793            Modifier::empty()
794        };
795        out.push(Span::styled(
796            format!(
797                " {} {}..{} ",
798                s.phase.label(),
799                s.start_block,
800                s.end_block
801            ),
802            Style::default().fg(color).add_modifier(modifier),
803        ));
804        if i + 1 < segs.len() {
805            out.push(Span::styled("│", Style::default().fg(t.dim)));
806        }
807    }
808    out
809}
810
811fn progress_bar_spans(rc: &RoundCard) -> Vec<Span<'static>> {
812    // 24-cell ASCII bar over 0..152.
813    const WIDTH: usize = 24;
814    let filled = ((rc.block_of_round as usize) * WIDTH) / BLOCKS_PER_ROUND as usize;
815    let mut bar = String::with_capacity(WIDTH);
816    for _ in 0..filled.min(WIDTH) {
817        bar.push('▇');
818    }
819    for _ in filled.min(WIDTH)..WIDTH {
820        bar.push('░');
821    }
822    vec![
823        Span::raw("  "),
824        Span::styled(bar, Style::default().fg(theme::active().warn)),
825        Span::raw(format!(
826            "   {}/{BLOCKS_PER_ROUND}",
827            rc.block_of_round
828        )),
829    ]
830}
831
832#[cfg(test)]
833mod tests {
834    use super::*;
835
836    #[test]
837    fn phase_from_api_known() {
838        assert_eq!(Phase::from_api("commit"), Phase::Commit);
839        assert_eq!(Phase::from_api("reveal"), Phase::Reveal);
840        assert_eq!(Phase::from_api("claim"), Phase::Claim);
841        assert_eq!(Phase::from_api("sample"), Phase::Sample);
842    }
843
844    #[test]
845    fn phase_from_api_unknown_falls_back() {
846        assert_eq!(Phase::from_api(""), Phase::Unknown);
847        assert_eq!(Phase::from_api("garbage"), Phase::Unknown);
848    }
849
850    #[test]
851    fn phase_segments_during_commit() {
852        let segs = build_phase_segments(Phase::Commit, 10);
853        assert_eq!(segs[0].state, PhaseState::Active);
854        assert_eq!(segs[1].state, PhaseState::Pending);
855        assert_eq!(segs[2].state, PhaseState::Pending);
856    }
857
858    #[test]
859    fn phase_segments_during_reveal() {
860        let segs = build_phase_segments(Phase::Reveal, 50);
861        assert_eq!(segs[0].state, PhaseState::Done);
862        assert_eq!(segs[1].state, PhaseState::Active);
863        assert_eq!(segs[2].state, PhaseState::Pending);
864    }
865
866    #[test]
867    fn phase_segments_during_claim() {
868        let segs = build_phase_segments(Phase::Claim, 100);
869        assert_eq!(segs[0].state, PhaseState::Done);
870        assert_eq!(segs[1].state, PhaseState::Done);
871        assert_eq!(segs[2].state, PhaseState::Active);
872    }
873
874    #[test]
875    fn phase_segments_during_sample() {
876        // Sample sits between rounds — every on-chain phase reads as Done.
877        let segs = build_phase_segments(Phase::Sample, 0);
878        for s in &segs {
879            assert_eq!(s.state, PhaseState::Done);
880        }
881    }
882
883    #[test]
884    fn format_when_handles_zero() {
885        assert_eq!(format_when(100, 0), "never");
886    }
887
888    #[test]
889    fn format_when_handles_current() {
890        assert_eq!(format_when(100, 100), "this round");
891    }
892
893    #[test]
894    fn format_when_handles_n_ago() {
895        assert_eq!(format_when(100, 95), "5 rounds ago");
896    }
897
898    #[test]
899    fn bench_depth_falls_back_when_status_missing() {
900        assert_eq!(bench_depth(&HealthSnapshot::default()), BENCH_DEFAULT_DEPTH);
901    }
902
903    #[test]
904    fn bench_depth_falls_back_on_sentinel() {
905        // Bee returns storage_radius = -1 before warmup completes.
906        let snap = HealthSnapshot {
907            status: Some(bee::debug::Status {
908                storage_radius: -1,
909                ..bee::debug::Status::default()
910            }),
911            ..HealthSnapshot::default()
912        };
913        assert_eq!(bench_depth(&snap), BENCH_DEFAULT_DEPTH);
914    }
915
916    #[test]
917    fn bench_depth_uses_storage_radius_when_present() {
918        let snap = HealthSnapshot {
919            status: Some(bee::debug::Status {
920                storage_radius: 12,
921                ..bee::debug::Status::default()
922            }),
923            ..HealthSnapshot::default()
924        };
925        assert_eq!(bench_depth(&snap), 12);
926    }
927}