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::watch::{HealthSnapshot, LotterySnapshot};
47
48use bee::debug::{RCHashResponse, RedistributionState};
49
50/// `pkg/storageincentives/agent.go:36` — round length in blocks.
51pub const BLOCKS_PER_ROUND: u64 = 152;
52/// One-quarter of the round (152 / 4); commit and reveal each take this many
53/// blocks, claim takes the remaining 76.
54pub const BLOCKS_PER_PHASE: u64 = 38;
55
56/// Discrete phase enum derived from the API's `phase: String`. Falls
57/// back to [`Phase::Unknown`] if Bee returns a value we don't model.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Phase {
60    Commit,
61    Reveal,
62    Claim,
63    /// In-between rounds — the agent is sampling chunks for the next
64    /// commit. Bee surfaces this as `phase: "sample"`.
65    Sample,
66    Unknown,
67}
68
69impl Phase {
70    fn from_api(s: &str) -> Self {
71        match s {
72            "commit" => Self::Commit,
73            "reveal" => Self::Reveal,
74            "claim" => Self::Claim,
75            "sample" => Self::Sample,
76            _ => Self::Unknown,
77        }
78    }
79
80    fn label(self) -> &'static str {
81        match self {
82            Self::Commit => "commit",
83            Self::Reveal => "reveal",
84            Self::Claim => "claim",
85            Self::Sample => "sample",
86            Self::Unknown => "?",
87        }
88    }
89}
90
91/// Tri-state per-phase outcome shown in the timeline ribbon.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum PhaseState {
94    /// Phase already completed in the current round.
95    Done,
96    /// Currently in this phase.
97    Active,
98    /// Phase hasn't started yet.
99    Pending,
100}
101
102/// One segment of the timeline ribbon.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct PhaseSegment {
105    pub phase: Phase,
106    pub state: PhaseState,
107    /// Inclusive lower bound of this phase in block-of-round.
108    pub start_block: u64,
109    /// Exclusive upper bound.
110    pub end_block: u64,
111}
112
113/// Top pane.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct RoundCard {
116    pub round: u64,
117    pub block: u64,
118    /// `block % BLOCKS_PER_ROUND` for quick visual reference.
119    pub block_of_round: u64,
120    /// Current phase as enum + label.
121    pub phase: Phase,
122    pub phase_label: &'static str,
123    pub segments: Vec<PhaseSegment>,
124}
125
126/// One row of the anchor-summary pane.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct AnchorRow {
129    pub label: &'static str,
130    pub round: u64,
131    /// `current_round - round`. `None` if the anchor round is 0
132    /// (never reached) or in the future.
133    pub delta: Option<u64>,
134    /// Human description: `"never"`, `"4 rounds ago"`, etc.
135    pub when: String,
136}
137
138/// Tri-state stake card status.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum StakeStatus {
141    /// `staked == 0` — node can't participate.
142    Unstaked,
143    /// 0 < staked but `has_sufficient_funds == false` — operator funded
144    /// the contract but the wallet doesn't have gas, so the node skips.
145    InsufficientGas,
146    /// Frozen out of redistribution by a previous bad commit.
147    Frozen,
148    /// Worker is unhealthy (storage radius drift, sample failures, etc.).
149    Unhealthy,
150    /// `staked > 0`, `has_sufficient_funds`, not frozen, healthy.
151    Healthy,
152    /// `/stake` failed.
153    Unknown,
154}
155
156impl StakeStatus {
157    fn color(self) -> Color {
158        match self {
159            Self::Unstaked => Color::Red,
160            Self::InsufficientGas => Color::Yellow,
161            Self::Frozen => Color::Red,
162            Self::Unhealthy => Color::Yellow,
163            Self::Healthy => Color::Green,
164            Self::Unknown => Color::DarkGray,
165        }
166    }
167    fn label(self) -> &'static str {
168        match self {
169            Self::Unstaked => "✗ unstaked",
170            Self::InsufficientGas => "⚠ low gas",
171            Self::Frozen => "✗ frozen",
172            Self::Unhealthy => "⚠ unhealthy",
173            Self::Healthy => "✓ healthy",
174            Self::Unknown => "? unknown",
175        }
176    }
177}
178
179/// Bottom pane.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct StakeCard {
182    pub status: StakeStatus,
183    /// Pre-formatted `BZZ x.xxxx` or `—`.
184    pub staked: String,
185    pub minimum_gas: String,
186    /// Cumulative reward (`BZZ x.xxxx` or `—`).
187    pub reward: String,
188    pub fees: String,
189    /// Last sample duration in seconds, formatted to one decimal.
190    pub last_sample: Option<String>,
191    /// Tooltip explaining the current state when not Healthy.
192    pub why: Option<String>,
193}
194
195/// Aggregated view fed to both renderer and snapshot tests.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct LotteryView {
198    pub round: Option<RoundCard>,
199    pub anchors: Vec<AnchorRow>,
200    pub stake: StakeCard,
201}
202
203/// Lifecycle of the on-demand rchash benchmark. Operator hits `r` to
204/// run; the component owns the request lifecycle via an internal mpsc
205/// channel so the App's action pipeline doesn't grow a one-off
206/// rchash-result variant.
207#[derive(Debug, Clone, PartialEq)]
208pub enum BenchState {
209    Idle,
210    Running,
211    Done {
212        duration_seconds: f64,
213        /// Reserve commitment hash (8-char prefix is what the screen
214        /// shows; full hash is kept here for the sake of completeness).
215        hash: String,
216    },
217    Failed {
218        error: String,
219    },
220}
221
222/// Fixed anchor pair used for the benchmark. Real-round anchors come
223/// off-chain; for a deterministic local sample we use 0x00…00 vs
224/// 0xff…ff so repeat measurements compare cleanly.
225const BENCH_ANCHOR_LO: &str =
226    "0000000000000000000000000000000000000000000000000000000000000000";
227const BENCH_ANCHOR_HI: &str =
228    "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
229/// Fallback depth when `/status` hasn't reported a `storage_radius`
230/// yet. 8 is a typical mainnet radius, so the sample size is
231/// representative.
232const BENCH_DEFAULT_DEPTH: u8 = 8;
233
234/// Pick a depth for the benchmark that mirrors what the agent would
235/// actually sample at: the node's current `storage_radius`. Falls back
236/// to [`BENCH_DEFAULT_DEPTH`] if `/status` hasn't loaded yet. Status's
237/// `storage_radius` is `i64` because the API can return a sentinel
238/// `-1`; we clamp to `0..=255` for the rchash path.
239pub fn bench_depth(health: &HealthSnapshot) -> u8 {
240    let raw = health
241        .status
242        .as_ref()
243        .map(|s| s.storage_radius)
244        .unwrap_or(-1);
245    if raw <= 0 {
246        BENCH_DEFAULT_DEPTH
247    } else {
248        raw.min(255) as u8
249    }
250}
251
252pub struct Lottery {
253    client: Arc<ApiClient>,
254    health_rx: watch::Receiver<HealthSnapshot>,
255    lottery_rx: watch::Receiver<LotterySnapshot>,
256    health: HealthSnapshot,
257    lottery: LotterySnapshot,
258    bench: BenchState,
259    bench_tx: mpsc::UnboundedSender<Result<RCHashResponse, String>>,
260    bench_rx: mpsc::UnboundedReceiver<Result<RCHashResponse, String>>,
261}
262
263impl Lottery {
264    pub fn new(
265        client: Arc<ApiClient>,
266        health_rx: watch::Receiver<HealthSnapshot>,
267        lottery_rx: watch::Receiver<LotterySnapshot>,
268    ) -> Self {
269        let health = health_rx.borrow().clone();
270        let lottery = lottery_rx.borrow().clone();
271        let (bench_tx, bench_rx) = mpsc::unbounded_channel();
272        Self {
273            client,
274            health_rx,
275            lottery_rx,
276            health,
277            lottery,
278            bench: BenchState::Idle,
279            bench_tx,
280            bench_rx,
281        }
282    }
283
284    fn pull_latest(&mut self) {
285        self.health = self.health_rx.borrow().clone();
286        self.lottery = self.lottery_rx.borrow().clone();
287    }
288
289    /// Drain any benchmark results that arrived since the last tick.
290    fn drain_bench_results(&mut self) {
291        while let Ok(result) = self.bench_rx.try_recv() {
292            self.bench = match result {
293                Ok(resp) => BenchState::Done {
294                    duration_seconds: resp.duration_seconds,
295                    hash: resp.hash,
296                },
297                Err(e) => BenchState::Failed { error: e },
298            };
299        }
300    }
301
302    /// Kick off a benchmark unless one is already in flight. Returns
303    /// `true` if a new request was spawned.
304    fn maybe_start_bench(&mut self) -> bool {
305        if matches!(self.bench, BenchState::Running) {
306            return false;
307        }
308        let depth = bench_depth(&self.health);
309        let client = self.client.clone();
310        let tx = self.bench_tx.clone();
311        tokio::spawn(async move {
312            let res = client
313                .bee()
314                .debug()
315                .r_chash(depth, BENCH_ANCHOR_LO, BENCH_ANCHOR_HI)
316                .await
317                .map_err(|e| e.to_string());
318            let _ = tx.send(res);
319        });
320        self.bench = BenchState::Running;
321        true
322    }
323
324    /// Pure, snapshot-driven view computation. Exposed for snapshot
325    /// tests in `tests/s4_lottery_view.rs`.
326    pub fn view_for(health: &HealthSnapshot, lottery: &LotterySnapshot) -> LotteryView {
327        let round = health.redistribution.as_ref().map(round_card_for);
328        let anchors = health
329            .redistribution
330            .as_ref()
331            .map(anchor_rows_for)
332            .unwrap_or_default();
333        let stake = stake_card_for(health.redistribution.as_ref(), lottery);
334        LotteryView {
335            round,
336            anchors,
337            stake,
338        }
339    }
340}
341
342fn round_card_for(r: &RedistributionState) -> RoundCard {
343    let block_of_round = r.block % BLOCKS_PER_ROUND;
344    let phase = Phase::from_api(&r.phase);
345
346    // Build the three on-chain phase segments. (Sample is between rounds and
347    // doesn't get a segment — when phase==Sample, the ribbon shows all three
348    // segments as Done.)
349    let segments = build_phase_segments(phase, block_of_round);
350
351    RoundCard {
352        round: r.round,
353        block: r.block,
354        block_of_round,
355        phase,
356        phase_label: phase.label(),
357        segments,
358    }
359}
360
361fn build_phase_segments(current: Phase, block_of_round: u64) -> Vec<PhaseSegment> {
362    let phases = [
363        (Phase::Commit, 0u64, BLOCKS_PER_PHASE),
364        (Phase::Reveal, BLOCKS_PER_PHASE, 2 * BLOCKS_PER_PHASE),
365        (Phase::Claim, 2 * BLOCKS_PER_PHASE, BLOCKS_PER_ROUND),
366    ];
367    phases
368        .iter()
369        .map(|&(p, start, end)| PhaseSegment {
370            phase: p,
371            state: phase_state_for(p, current, block_of_round, start, end),
372            start_block: start,
373            end_block: end,
374        })
375        .collect()
376}
377
378fn phase_state_for(
379    seg: Phase,
380    current: Phase,
381    block_of_round: u64,
382    start: u64,
383    end: u64,
384) -> PhaseState {
385    // Trust the API's `phase` field for the active marker, but fall back to
386    // block-range arithmetic when it reports `sample` (between rounds —
387    // every commit/reveal/claim is Done) or `unknown`.
388    if current == seg {
389        return PhaseState::Active;
390    }
391    if matches!(current, Phase::Sample) {
392        return PhaseState::Done;
393    }
394    if block_of_round >= end {
395        PhaseState::Done
396    } else if block_of_round < start {
397        PhaseState::Pending
398    } else {
399        // Block-of-round is inside our segment but the API claims a different
400        // phase — surface as Active anyway, the API is authoritative for the
401        // current marker.
402        PhaseState::Active
403    }
404}
405
406fn anchor_rows_for(r: &RedistributionState) -> Vec<AnchorRow> {
407    let current = r.round;
408    let make = |label: &'static str, anchor: u64| AnchorRow {
409        label,
410        round: anchor,
411        delta: if anchor == 0 || anchor > current {
412            None
413        } else {
414            Some(current - anchor)
415        },
416        when: format_when(current, anchor),
417    };
418    vec![
419        make("last won", r.last_won_round),
420        make("last played", r.last_played_round),
421        make("last selected", r.last_selected_round),
422        make("last frozen", r.last_frozen_round),
423    ]
424}
425
426fn format_when(current: u64, anchor: u64) -> String {
427    if anchor == 0 {
428        return "never".into();
429    }
430    if anchor > current {
431        return format!("round {anchor} (future)");
432    }
433    let delta = current - anchor;
434    match delta {
435        0 => "this round".into(),
436        1 => "last round".into(),
437        n => format!("{n} rounds ago"),
438    }
439}
440
441fn stake_card_for(r: Option<&RedistributionState>, lottery: &LotterySnapshot) -> StakeCard {
442    let zero = BigInt::from(0);
443    let staked_bi = lottery.staked.as_ref();
444    let staked_str = staked_bi.map(format_plur).unwrap_or_else(|| "—".into());
445
446    let (minimum_gas, reward, fees, last_sample, status_inputs) = match r {
447        Some(r) => (
448            r.minimum_gas_funds
449                .as_ref()
450                .map(format_plur)
451                .unwrap_or_else(|| "—".into()),
452            r.reward
453                .as_ref()
454                .map(format_plur)
455                .unwrap_or_else(|| "—".into()),
456            r.fees
457                .as_ref()
458                .map(format_plur)
459                .unwrap_or_else(|| "—".into()),
460            (r.last_sample_duration_seconds > 0.0)
461                .then(|| format!("{:.1}s", r.last_sample_duration_seconds)),
462            Some((
463                r.is_frozen,
464                r.is_healthy,
465                r.has_sufficient_funds,
466                r.is_fully_synced,
467                r.last_frozen_round,
468            )),
469        ),
470        None => (
471            "—".into(),
472            "—".into(),
473            "—".into(),
474            None,
475            None,
476        ),
477    };
478
479    let (status, why) = match (lottery.last_error.as_deref(), staked_bi, status_inputs) {
480        (Some(e), _, _) => (StakeStatus::Unknown, Some(format!("/stake error: {e}"))),
481        (_, None, _) => (StakeStatus::Unknown, Some("/stake not loaded yet".into())),
482        (_, Some(s), Some((frozen, healthy, sufficient, synced, last_frozen)))
483            if s == &zero =>
484        {
485            let _ = (frozen, healthy, sufficient, synced, last_frozen);
486            (
487                StakeStatus::Unstaked,
488                Some("0 BZZ staked — node cannot participate in redistribution.".into()),
489            )
490        }
491        (_, Some(_), Some((true, _, _, _, last_frozen))) => (
492            StakeStatus::Frozen,
493            Some(format!(
494                "frozen out at round {last_frozen}; resumes after the freeze window."
495            )),
496        ),
497        (_, Some(_), Some((_, _, false, _, _))) => (
498            StakeStatus::InsufficientGas,
499            Some("operator wallet has too little native token to play a round.".into()),
500        ),
501        (_, Some(_), Some((_, false, _, _, _))) => (
502            StakeStatus::Unhealthy,
503            Some("redistribution worker reports unhealthy — see Health screen.".into()),
504        ),
505        (_, Some(_), Some((_, _, _, false, _))) => (
506            StakeStatus::Unhealthy,
507            Some("node is not fully synced — sampling will skip until it catches up.".into()),
508        ),
509        (_, Some(_), Some(_)) => (StakeStatus::Healthy, None),
510        // Stake known but no redistribution data yet.
511        (_, Some(s), None) if s == &zero => (
512            StakeStatus::Unstaked,
513            Some("0 BZZ staked — node cannot participate in redistribution.".into()),
514        ),
515        (_, Some(_), None) => (
516            StakeStatus::Unknown,
517            Some("redistribution state not loaded yet".into()),
518        ),
519    };
520
521    StakeCard {
522        status,
523        staked: staked_str,
524        minimum_gas,
525        reward,
526        fees,
527        last_sample,
528        why,
529    }
530}
531
532impl Component for Lottery {
533    fn update(&mut self, action: Action) -> Result<Option<Action>> {
534        if matches!(action, Action::Tick) {
535            self.pull_latest();
536            self.drain_bench_results();
537        }
538        Ok(None)
539    }
540
541    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
542        if matches!(key.code, KeyCode::Char('r')) {
543            self.maybe_start_bench();
544        }
545        Ok(None)
546    }
547
548    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
549        let chunks = Layout::vertical([
550            Constraint::Length(3), // header
551            Constraint::Length(6), // round timeline
552            Constraint::Length(7), // anchors
553            Constraint::Min(0),    // stake card
554            Constraint::Length(1), // footer
555        ])
556        .split(area);
557
558        // Header
559        let header_l1 = Line::from(vec![Span::styled(
560            "LOTTERY / REDISTRIBUTION",
561            Style::default().add_modifier(Modifier::BOLD),
562        )]);
563        let mut header_l2 = Vec::new();
564        if let Some(err) = &self.lottery.last_error {
565            header_l2.push(Span::styled(
566                format!("partial: {err}"),
567                Style::default().fg(Color::Red),
568            ));
569        } else if !self.lottery.is_loaded() {
570            header_l2.push(Span::styled(
571                "loading…",
572                Style::default().fg(Color::DarkGray),
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(Color::DarkGray),
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(Color::DarkGray)
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(Color::DarkGray)
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(Color::DarkGray)
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(Color::DarkGray)),
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(Color::Cyan)),
679                Span::styled(
680                    "   (deadline ≈ 95s for the 38-block commit window)",
681                    Style::default().fg(Color::DarkGray),
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(Color::DarkGray)
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(Color::DarkGray),
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(Color::DarkGray),
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(Color::Cyan),
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(Color::Green)
737                } else {
738                    Style::default().fg(Color::Red).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(Color::DarkGray)),
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(Color::Red),
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 mut out = vec![Span::raw("  ")];
783    for (i, s) in segs.iter().enumerate() {
784        let color = match s.state {
785            PhaseState::Done => Color::DarkGray,
786            PhaseState::Active => Color::Yellow,
787            PhaseState::Pending => Color::White,
788        };
789        let modifier = if matches!(s.state, PhaseState::Active) {
790            Modifier::BOLD
791        } else {
792            Modifier::empty()
793        };
794        out.push(Span::styled(
795            format!(
796                " {} {}..{} ",
797                s.phase.label(),
798                s.start_block,
799                s.end_block
800            ),
801            Style::default().fg(color).add_modifier(modifier),
802        ));
803        if i + 1 < segs.len() {
804            out.push(Span::styled("│", Style::default().fg(Color::DarkGray)));
805        }
806    }
807    out
808}
809
810fn progress_bar_spans(rc: &RoundCard) -> Vec<Span<'static>> {
811    // 24-cell ASCII bar over 0..152.
812    const WIDTH: usize = 24;
813    let filled = ((rc.block_of_round as usize) * WIDTH) / BLOCKS_PER_ROUND as usize;
814    let mut bar = String::with_capacity(WIDTH);
815    for _ in 0..filled.min(WIDTH) {
816        bar.push('▇');
817    }
818    for _ in filled.min(WIDTH)..WIDTH {
819        bar.push('░');
820    }
821    vec![
822        Span::raw("  "),
823        Span::styled(bar, Style::default().fg(Color::Yellow)),
824        Span::raw(format!(
825            "   {}/{BLOCKS_PER_ROUND}",
826            rc.block_of_round
827        )),
828    ]
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[test]
836    fn phase_from_api_known() {
837        assert_eq!(Phase::from_api("commit"), Phase::Commit);
838        assert_eq!(Phase::from_api("reveal"), Phase::Reveal);
839        assert_eq!(Phase::from_api("claim"), Phase::Claim);
840        assert_eq!(Phase::from_api("sample"), Phase::Sample);
841    }
842
843    #[test]
844    fn phase_from_api_unknown_falls_back() {
845        assert_eq!(Phase::from_api(""), Phase::Unknown);
846        assert_eq!(Phase::from_api("garbage"), Phase::Unknown);
847    }
848
849    #[test]
850    fn phase_segments_during_commit() {
851        let segs = build_phase_segments(Phase::Commit, 10);
852        assert_eq!(segs[0].state, PhaseState::Active);
853        assert_eq!(segs[1].state, PhaseState::Pending);
854        assert_eq!(segs[2].state, PhaseState::Pending);
855    }
856
857    #[test]
858    fn phase_segments_during_reveal() {
859        let segs = build_phase_segments(Phase::Reveal, 50);
860        assert_eq!(segs[0].state, PhaseState::Done);
861        assert_eq!(segs[1].state, PhaseState::Active);
862        assert_eq!(segs[2].state, PhaseState::Pending);
863    }
864
865    #[test]
866    fn phase_segments_during_claim() {
867        let segs = build_phase_segments(Phase::Claim, 100);
868        assert_eq!(segs[0].state, PhaseState::Done);
869        assert_eq!(segs[1].state, PhaseState::Done);
870        assert_eq!(segs[2].state, PhaseState::Active);
871    }
872
873    #[test]
874    fn phase_segments_during_sample() {
875        // Sample sits between rounds — every on-chain phase reads as Done.
876        let segs = build_phase_segments(Phase::Sample, 0);
877        for s in &segs {
878            assert_eq!(s.state, PhaseState::Done);
879        }
880    }
881
882    #[test]
883    fn format_when_handles_zero() {
884        assert_eq!(format_when(100, 0), "never");
885    }
886
887    #[test]
888    fn format_when_handles_current() {
889        assert_eq!(format_when(100, 100), "this round");
890    }
891
892    #[test]
893    fn format_when_handles_n_ago() {
894        assert_eq!(format_when(100, 95), "5 rounds ago");
895    }
896
897    #[test]
898    fn bench_depth_falls_back_when_status_missing() {
899        assert_eq!(bench_depth(&HealthSnapshot::default()), BENCH_DEFAULT_DEPTH);
900    }
901
902    #[test]
903    fn bench_depth_falls_back_on_sentinel() {
904        // Bee returns storage_radius = -1 before warmup completes.
905        let snap = HealthSnapshot {
906            status: Some(bee::debug::Status {
907                storage_radius: -1,
908                ..bee::debug::Status::default()
909            }),
910            ..HealthSnapshot::default()
911        };
912        assert_eq!(bench_depth(&snap), BENCH_DEFAULT_DEPTH);
913    }
914
915    #[test]
916    fn bench_depth_uses_storage_radius_when_present() {
917        let snap = HealthSnapshot {
918            status: Some(bee::debug::Status {
919                storage_radius: 12,
920                ..bee::debug::Status::default()
921            }),
922            ..HealthSnapshot::default()
923        };
924        assert_eq!(bench_depth(&snap), 12);
925    }
926}