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