Skip to main content

bee_tui/components/
swap.rs

1//! S3 — SWAP / cheques screen (`docs/PLAN.md` § 8.S3).
2//!
3//! Three stacked panes driven by [`crate::watch::SwapSnapshot`]:
4//!
5//! 1. **Chequebook balance card** — total + available, with the
6//!    headroom (`available / total`) called out so operators notice
7//!    when they're approaching cashout pressure.
8//! 2. **Last received cheques** (per peer) — sorted by cumulative
9//!    payout descending so the most lucrative peers float to the top.
10//!    A peer with no cheque yet is rendered as a quiet "—" row instead
11//!    of being hidden, because "no cheques" is itself useful signal.
12//! 3. **Per-peer settlements table** — received and sent PLUR side by
13//!    side, with the net (received − sent) flagged. SWAP keeps these
14//!    near zero in steady state; a large net delta means cheques are
15//!    being issued faster than they're being cashed.
16//!
17//! Render path delegates to the pure [`Swap::view_for`] function so
18//! `tests/s3_swap_view.rs` can pin all the formatting (PLUR scaling,
19//! sort order, net-balance highlighting) without launching a TUI.
20
21use color_eyre::Result;
22use num_bigint::BigInt;
23use ratatui::{
24    Frame,
25    layout::{Constraint, Layout, Rect},
26    style::{Color, Modifier, Style},
27    text::{Line, Span},
28    widgets::{Block, Borders, Paragraph},
29};
30use tokio::sync::watch;
31
32use super::Component;
33use crate::action::Action;
34use crate::watch::SwapSnapshot;
35
36use bee::debug::{ChequebookBalance, LastCheque, Settlement, Settlements};
37
38/// Tri-state outcome for the chequebook balance card.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum SwapStatus {
41    /// `total_balance` is zero — chequebook hasn't been funded yet.
42    Empty,
43    /// Available balance is healthy (>20 % of total).
44    Healthy,
45    /// Available drops below 20 % of total — most funds are tied up
46    /// in unsettled debt and cashing out is getting close.
47    Tight,
48    /// `/chequebook/balance` failed; nothing to render.
49    Unknown,
50}
51
52impl SwapStatus {
53    fn color(self) -> Color {
54        match self {
55            Self::Empty => Color::Yellow,
56            Self::Healthy => Color::Green,
57            Self::Tight => Color::Yellow,
58            Self::Unknown => Color::DarkGray,
59        }
60    }
61    fn label(self) -> &'static str {
62        match self {
63            Self::Empty => "○ unfunded",
64            Self::Healthy => "✓ healthy",
65            Self::Tight => "⚠ tight",
66            Self::Unknown => "? unknown",
67        }
68    }
69}
70
71/// Snapshot-friendly view of the chequebook card.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ChequebookCard {
74    pub status: SwapStatus,
75    /// PLUR formatted as `BZZ x.xxxx` (BZZ has 16 decimals).
76    pub total: String,
77    pub available: String,
78    /// `available / total` as 0..=100. `0` if total is zero.
79    pub available_pct: u32,
80    pub why: Option<String>,
81}
82
83/// One row of the "last received cheques" pane.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct CheckRow {
86    pub peer_short: String,
87    /// Pre-formatted payout (`BZZ x.xxxx` or `—` if no cheque yet).
88    pub payout: String,
89    /// `true` if this peer has not sent us any cheque yet.
90    pub never: bool,
91}
92
93/// One row of the per-peer settlements pane.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SettlementRow {
96    pub peer_short: String,
97    pub received: String,
98    pub sent: String,
99    /// Sign-prefixed net (`+x` if we're owed, `-x` if we owe).
100    pub net: String,
101    /// `true` when |net| > 0.5 BZZ — flagged in red.
102    pub net_flagged: bool,
103}
104
105/// Aggregated view fed to both the renderer and snapshot tests.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct SwapView {
108    pub card: ChequebookCard,
109    pub cheques: Vec<CheckRow>,
110    pub settlements: Vec<SettlementRow>,
111    pub time_total_received: Option<String>,
112    pub time_total_sent: Option<String>,
113}
114
115pub struct Swap {
116    rx: watch::Receiver<SwapSnapshot>,
117    snapshot: SwapSnapshot,
118}
119
120impl Swap {
121    pub fn new(rx: watch::Receiver<SwapSnapshot>) -> Self {
122        let snapshot = rx.borrow().clone();
123        Self { rx, snapshot }
124    }
125
126    fn pull_latest(&mut self) {
127        self.snapshot = self.rx.borrow().clone();
128    }
129
130    /// Pure, snapshot-driven view computation. Exposed for snapshot
131    /// tests in `tests/s3_swap_view.rs`.
132    pub fn view_for(snap: &SwapSnapshot) -> SwapView {
133        let card = card_for(snap.chequebook.as_ref());
134        let cheques = cheque_rows_for(&snap.last_received);
135        let settlements = settlement_rows_for(snap.settlements.as_ref());
136        let time_total_received = snap
137            .time_settlements
138            .as_ref()
139            .and_then(|s| s.total_received.as_ref())
140            .map(format_plur);
141        let time_total_sent = snap
142            .time_settlements
143            .as_ref()
144            .and_then(|s| s.total_sent.as_ref())
145            .map(format_plur);
146        SwapView {
147            card,
148            cheques,
149            settlements,
150            time_total_received,
151            time_total_sent,
152        }
153    }
154}
155
156fn card_for(cb: Option<&ChequebookBalance>) -> ChequebookCard {
157    let Some(cb) = cb else {
158        return ChequebookCard {
159            status: SwapStatus::Unknown,
160            total: "—".into(),
161            available: "—".into(),
162            available_pct: 0,
163            why: Some("/chequebook/balance not available yet".into()),
164        };
165    };
166    let zero = BigInt::from(0);
167    let total = &cb.total_balance;
168    let avail = &cb.available_balance;
169    let total_str = format_plur(total);
170    let avail_str = format_plur(avail);
171    if total == &zero {
172        return ChequebookCard {
173            status: SwapStatus::Empty,
174            total: total_str,
175            available: avail_str,
176            available_pct: 0,
177            why: Some("chequebook holds 0 BZZ — fund it to send cheques.".into()),
178        };
179    }
180    let pct = pct_of(avail, total);
181    let (status, why) = if pct < 20 {
182        (
183            SwapStatus::Tight,
184            Some(format!(
185                "only {pct}% available — most BZZ is tied up in unsettled debt."
186            )),
187        )
188    } else {
189        (SwapStatus::Healthy, None)
190    };
191    ChequebookCard {
192        status,
193        total: total_str,
194        available: avail_str,
195        available_pct: pct,
196        why,
197    }
198}
199
200fn cheque_rows_for(last_received: &[LastCheque]) -> Vec<CheckRow> {
201    let mut rows: Vec<CheckRow> = last_received
202        .iter()
203        .map(|lc| {
204            let payout_bi = lc.last_received.as_ref().and_then(|c| c.payout.as_ref());
205            let (payout, never) = match payout_bi {
206                Some(p) => (format_plur(p), false),
207                None => ("—".into(), true),
208            };
209            CheckRow {
210                peer_short: short_peer(&lc.peer),
211                payout,
212                never,
213            }
214        })
215        .collect();
216    // Sort: peers with cheques first, by payout descending (string-sort is OK
217    // because format_plur produces fixed-decimal `BZZ x.xxxx` form). Peers with
218    // no cheque sink to the bottom but stay visible.
219    rows.sort_by(|a, b| match (a.never, b.never) {
220        (false, true) => std::cmp::Ordering::Less,
221        (true, false) => std::cmp::Ordering::Greater,
222        _ => b.payout.cmp(&a.payout),
223    });
224    rows
225}
226
227fn settlement_rows_for(s: Option<&Settlements>) -> Vec<SettlementRow> {
228    let Some(s) = s else { return Vec::new() };
229    // Sort the raw settlements by |received - sent| descending so the
230    // most out-of-balance peers float to the top — that's where cashout
231    // pressure shows up first.
232    let mut sorted: Vec<&Settlement> = s.settlements.iter().collect();
233    sorted.sort_by_key(|s| std::cmp::Reverse(abs_net(s)));
234    sorted.into_iter().map(settlement_row).collect()
235}
236
237fn abs_net(s: &Settlement) -> BigInt {
238    let zero = BigInt::from(0);
239    let recv = s.received.as_ref().unwrap_or(&zero);
240    let sent = s.sent.as_ref().unwrap_or(&zero);
241    let net = recv - sent;
242    if net < zero { -net } else { net }
243}
244
245fn settlement_row(s: &Settlement) -> SettlementRow {
246    let zero = BigInt::from(0);
247    let recv = s.received.as_ref().unwrap_or(&zero);
248    let sent = s.sent.as_ref().unwrap_or(&zero);
249    let net_bi = recv - sent;
250    let net = format_plur_signed(&net_bi);
251    // Flag peers >0.5 BZZ out of balance (5 * 10^15 PLUR).
252    let half_bzz = BigInt::from(5_000_000_000_000_000u64);
253    let abs = if net_bi < BigInt::from(0) {
254        -net_bi
255    } else {
256        net_bi
257    };
258    let net_flagged = abs > half_bzz;
259    SettlementRow {
260        peer_short: short_peer(&s.peer),
261        received: format_plur(recv),
262        sent: format_plur(sent),
263        net,
264        net_flagged,
265    }
266}
267
268/// Format a PLUR amount as `BZZ x.xxxx` with 4 fractional digits.
269/// Bee's PLUR has 16 decimals; we render the top 4 digits of the
270/// fractional part so 0.0001 BZZ is the smallest visible unit, which
271/// is finer than any realistic per-cheque amount.
272pub fn format_plur(plur: &BigInt) -> String {
273    format_plur_inner(plur, false)
274}
275
276fn format_plur_signed(plur: &BigInt) -> String {
277    format_plur_inner(plur, true)
278}
279
280fn format_plur_inner(plur: &BigInt, signed: bool) -> String {
281    let zero = BigInt::from(0);
282    let neg = plur < &zero;
283    let abs = if neg { -plur.clone() } else { plur.clone() };
284    let scale = BigInt::from(10u64).pow(16);
285    let whole = &abs / &scale;
286    let frac = &abs % &scale;
287    // Take top 4 digits: divide by 10^12.
288    let frac_4 = &frac / BigInt::from(10u64).pow(12);
289    let sign = if neg {
290        "-"
291    } else if signed {
292        "+"
293    } else {
294        ""
295    };
296    format!("{sign}BZZ {whole}.{frac_4:0>4}")
297}
298
299fn pct_of(num: &BigInt, denom: &BigInt) -> u32 {
300    let zero = BigInt::from(0);
301    if denom == &zero {
302        return 0;
303    }
304    // (num * 100) / denom, clamped to 100.
305    let scaled = num * BigInt::from(100);
306    let q = &scaled / denom;
307    // Convert to u32 best-effort; in practice 0..=100.
308    let q_str = q.to_string();
309    let q_u: u128 = q_str.parse().unwrap_or(0);
310    q_u.min(100) as u32
311}
312
313fn short_peer(p: &str) -> String {
314    let trimmed = p.trim_start_matches("0x");
315    if trimmed.len() > 10 {
316        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
317    } else {
318        trimmed.to_string()
319    }
320}
321
322impl Component for Swap {
323    fn update(&mut self, action: Action) -> Result<Option<Action>> {
324        if matches!(action, Action::Tick) {
325            self.pull_latest();
326        }
327        Ok(None)
328    }
329
330    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
331        let chunks = Layout::vertical([
332            Constraint::Length(3), // header
333            Constraint::Length(5), // chequebook card
334            Constraint::Min(0),    // tables
335            Constraint::Length(1), // footer
336        ])
337        .split(area);
338
339        // Header
340        let header_l1 = Line::from(vec![Span::styled(
341            "SWAP / CHEQUES",
342            Style::default().add_modifier(Modifier::BOLD),
343        )]);
344        let mut header_l2 = Vec::new();
345        if let Some(err) = &self.snapshot.last_error {
346            header_l2.push(Span::styled(
347                format!("partial: {err}"),
348                Style::default().fg(Color::Red),
349            ));
350        } else if !self.snapshot.is_loaded() {
351            header_l2.push(Span::styled(
352                "loading…",
353                Style::default().fg(Color::DarkGray),
354            ));
355        }
356        frame.render_widget(
357            Paragraph::new(vec![header_l1, Line::from(header_l2)])
358                .block(Block::default().borders(Borders::BOTTOM)),
359            chunks[0],
360        );
361
362        let view = Self::view_for(&self.snapshot);
363
364        // Chequebook card
365        let card = &view.card;
366        let mut card_lines = vec![
367            Line::from(vec![
368                Span::styled(
369                    "  Chequebook  ",
370                    Style::default().add_modifier(Modifier::BOLD),
371                ),
372                Span::styled(
373                    card.status.label(),
374                    Style::default()
375                        .fg(card.status.color())
376                        .add_modifier(Modifier::BOLD),
377                ),
378            ]),
379            Line::from(vec![
380                Span::raw(format!("    total      {}", card.total)),
381                Span::raw("   "),
382                Span::raw(format!("available  {}", card.available)),
383                Span::raw("   "),
384                Span::styled(
385                    format!("({}% available)", card.available_pct),
386                    Style::default().fg(Color::DarkGray),
387                ),
388            ]),
389        ];
390        if let Some(why) = &card.why {
391            card_lines.push(Line::from(vec![
392                Span::raw("    └─ "),
393                Span::styled(
394                    why.clone(),
395                    Style::default()
396                        .fg(Color::DarkGray)
397                        .add_modifier(Modifier::ITALIC),
398                ),
399            ]));
400        }
401        frame.render_widget(
402            Paragraph::new(card_lines).block(Block::default().borders(Borders::BOTTOM)),
403            chunks[1],
404        );
405
406        // Tables stacked: cheques (top) + settlements (bottom)
407        let table_chunks =
408            Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
409                .split(chunks[2]);
410
411        // Cheques table
412        let mut cheque_lines: Vec<Line> = vec![Line::from(Span::styled(
413            "  PEER          LAST RECEIVED",
414            Style::default()
415                .fg(Color::DarkGray)
416                .add_modifier(Modifier::BOLD),
417        ))];
418        if view.cheques.is_empty() {
419            cheque_lines.push(Line::from(Span::styled(
420                "  (no peer cheques known yet)",
421                Style::default()
422                    .fg(Color::DarkGray)
423                    .add_modifier(Modifier::ITALIC),
424            )));
425        } else {
426            for r in &view.cheques {
427                let payout_style = if r.never {
428                    Style::default().fg(Color::DarkGray)
429                } else {
430                    Style::default().fg(Color::Green)
431                };
432                cheque_lines.push(Line::from(vec![
433                    Span::raw("  "),
434                    Span::raw(format!("{:<14}", r.peer_short)),
435                    Span::styled(r.payout.clone(), payout_style),
436                ]));
437            }
438        }
439        frame.render_widget(
440            Paragraph::new(cheque_lines).block(
441                Block::default()
442                    .borders(Borders::BOTTOM)
443                    .title(Span::styled(
444                        " last cheques ",
445                        Style::default().add_modifier(Modifier::BOLD),
446                    )),
447            ),
448            table_chunks[0],
449        );
450
451        // Settlements table
452        let mut settle_lines: Vec<Line> = vec![Line::from(Span::styled(
453            "  PEER          RECEIVED              SENT                 NET",
454            Style::default()
455                .fg(Color::DarkGray)
456                .add_modifier(Modifier::BOLD),
457        ))];
458        if let (Some(tr), Some(ts)) = (&view.time_total_received, &view.time_total_sent) {
459            settle_lines.push(Line::from(vec![Span::styled(
460                format!("  time-based totals — received {tr} · sent {ts}"),
461                Style::default().fg(Color::DarkGray),
462            )]));
463        }
464        if view.settlements.is_empty() {
465            settle_lines.push(Line::from(Span::styled(
466                "  (no peer settlements yet)",
467                Style::default()
468                    .fg(Color::DarkGray)
469                    .add_modifier(Modifier::ITALIC),
470            )));
471        } else {
472            for r in &view.settlements {
473                let net_style = if r.net_flagged {
474                    Style::default()
475                        .fg(Color::Red)
476                        .add_modifier(Modifier::BOLD)
477                } else {
478                    Style::default().fg(Color::DarkGray)
479                };
480                settle_lines.push(Line::from(vec![
481                    Span::raw("  "),
482                    Span::raw(format!("{:<14}", r.peer_short)),
483                    Span::raw(format!("{:<22}", r.received)),
484                    Span::raw(format!("{:<21}", r.sent)),
485                    Span::styled(r.net.clone(), net_style),
486                ]));
487            }
488        }
489        frame.render_widget(
490            Paragraph::new(settle_lines).block(Block::default().title(Span::styled(
491                " settlements ",
492                Style::default().add_modifier(Modifier::BOLD),
493            ))),
494            table_chunks[1],
495        );
496
497        // Footer
498        frame.render_widget(
499            Paragraph::new(Line::from(vec![
500                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
501                Span::raw(" switch screen  "),
502                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
503                Span::raw(" quit  "),
504                Span::styled(" net ", Style::default().fg(Color::Red)),
505                Span::raw(" out-of-balance peer (>0.5 BZZ) "),
506            ])),
507            chunks[3],
508        );
509
510        Ok(())
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn format_plur_zero() {
520        assert_eq!(format_plur(&BigInt::from(0)), "BZZ 0.0000");
521    }
522
523    #[test]
524    fn format_plur_one_bzz() {
525        let one = BigInt::from(10u64).pow(16);
526        assert_eq!(format_plur(&one), "BZZ 1.0000");
527    }
528
529    #[test]
530    fn format_plur_fractional() {
531        // 0.5 BZZ = 5 * 10^15 PLUR
532        let half = BigInt::from(5_000_000_000_000_000u64);
533        assert_eq!(format_plur(&half), "BZZ 0.5000");
534    }
535
536    #[test]
537    fn format_plur_signed_negative() {
538        let one = BigInt::from(10u64).pow(16);
539        assert_eq!(format_plur_signed(&-one), "-BZZ 1.0000");
540    }
541
542    #[test]
543    fn format_plur_signed_positive() {
544        let one = BigInt::from(10u64).pow(16);
545        assert_eq!(format_plur_signed(&one), "+BZZ 1.0000");
546    }
547
548    #[test]
549    fn pct_of_handles_zero_denom() {
550        assert_eq!(pct_of(&BigInt::from(10), &BigInt::from(0)), 0);
551    }
552
553    #[test]
554    fn pct_of_clamps_to_100() {
555        assert_eq!(pct_of(&BigInt::from(200), &BigInt::from(100)), 100);
556    }
557
558    #[test]
559    fn short_peer_truncates_long_overlay() {
560        let p = "0xabcdef0123456789abcdef0123456789";
561        let s = short_peer(p);
562        assert!(s.contains('…'));
563        assert!(s.starts_with("abcdef"));
564    }
565
566    #[test]
567    fn short_peer_passes_short_through() {
568        assert_eq!(short_peer("abcd"), "abcd");
569    }
570}