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    /// Pre-rendered Market tile lines. `None` when the operator
120    /// hasn't enabled `[economics].enable_market_tile` — the
121    /// renderer skips the tile row entirely in that case.
122    pub market: Option<MarketTile>,
123}
124
125/// Snapshot of the Market tile lines. Pure data so snapshot tests
126/// can pin the formatted strings without poking the renderer.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct MarketTile {
129    /// `BZZ ≈ $0.4321` — `—` when price hasn't loaded yet.
130    pub price_line: String,
131    /// `gas: 1.20 base + 0.50 tip = 1.70 gwei` — `—` when not
132    /// configured or basefee fetch failed.
133    pub gas_line: String,
134    /// "stale: ..." line shown dim+italic under the tile when the
135    /// last poll surfaced an error. `None` when the latest poll
136    /// was clean, or when no poll has run yet (cold start shows a
137    /// `loading…` line via `cold_start` instead).
138    pub stale_why: Option<String>,
139    /// `true` while no poll has completed — drives the spinner
140    /// glyph in the tile so cold-start motion is visible.
141    pub cold_start: bool,
142}
143
144pub struct Swap {
145    rx: watch::Receiver<SwapSnapshot>,
146    snapshot: SwapSnapshot,
147    /// Optional cost-context feed driving the Market tile. `None`
148    /// when `[economics].enable_market_tile` is off — the tile is
149    /// suppressed entirely in that case so the layout doesn't waste
150    /// a row on placeholder text.
151    market_rx: Option<watch::Receiver<crate::economics_oracle::EconomicsSnapshot>>,
152    market: crate::economics_oracle::EconomicsSnapshot,
153}
154
155impl Swap {
156    pub fn new(rx: watch::Receiver<SwapSnapshot>) -> Self {
157        let snapshot = rx.borrow().clone();
158        Self {
159            rx,
160            snapshot,
161            market_rx: None,
162            market: crate::economics_oracle::EconomicsSnapshot::default(),
163        }
164    }
165
166    /// Builder-style attach for the cost-context feed. Call once
167    /// after `new` when `[economics].enable_market_tile` is true.
168    pub fn with_market_feed(
169        mut self,
170        rx: watch::Receiver<crate::economics_oracle::EconomicsSnapshot>,
171    ) -> Self {
172        self.market = rx.borrow().clone();
173        self.market_rx = Some(rx);
174        self
175    }
176
177    fn pull_latest(&mut self) {
178        self.snapshot = self.rx.borrow().clone();
179        if let Some(rx) = &self.market_rx {
180            self.market = rx.borrow().clone();
181        }
182    }
183
184    /// Pure, snapshot-driven view computation. Exposed for snapshot
185    /// tests in `tests/s3_swap_view.rs`. The `market` argument is
186    /// `None` when `[economics].enable_market_tile` is off — the
187    /// tile is suppressed entirely in that case so cold-start
188    /// renders identically to today.
189    pub fn view_for(
190        snap: &SwapSnapshot,
191        market: Option<&crate::economics_oracle::EconomicsSnapshot>,
192    ) -> SwapView {
193        let card = card_for(snap.chequebook.as_ref());
194        let cheques = cheque_rows_for(&snap.last_received);
195        let settlements = settlement_rows_for(snap.settlements.as_ref());
196        let time_total_received = snap
197            .time_settlements
198            .as_ref()
199            .and_then(|s| s.total_received.as_ref())
200            .map(format_plur);
201        let time_total_sent = snap
202            .time_settlements
203            .as_ref()
204            .and_then(|s| s.total_sent.as_ref())
205            .map(format_plur);
206        let market = market.map(market_tile_for);
207        SwapView {
208            card,
209            chequebook_address: snap.chequebook_address.clone(),
210            cheques,
211            settlements,
212            time_total_received,
213            time_total_sent,
214            market,
215        }
216    }
217
218    /// Convenience wrapper used by snapshot tests that don't care
219    /// about the optional Market tile. Equivalent to
220    /// `view_for(snap, None)`.
221    pub fn view_for_no_market(snap: &SwapSnapshot) -> SwapView {
222        Self::view_for(snap, None)
223    }
224}
225
226/// Pure formatter — exposed for tests. Builds the four tile lines
227/// from the latest poller snapshot.
228fn market_tile_for(m: &crate::economics_oracle::EconomicsSnapshot) -> MarketTile {
229    let price_line = match &m.price {
230        Some(p) => format!("BZZ ≈ ${:.4}", p.usd),
231        None => "BZZ ≈ —".to_string(),
232    };
233    let gas_line = match &m.gas {
234        Some(g) => match g.max_priority_fee_gwei {
235            Some(tip) => format!(
236                "gas: {:.2} base + {:.2} tip = {:.2} gwei",
237                g.base_fee_gwei,
238                tip,
239                g.total_gwei(),
240            ),
241            None => format!("gas: {:.2} gwei base", g.base_fee_gwei),
242        },
243        None => "gas: —".to_string(),
244    };
245    MarketTile {
246        price_line,
247        gas_line,
248        stale_why: m.last_error.clone(),
249        cold_start: m.last_polled.is_none(),
250    }
251}
252
253fn card_for(cb: Option<&ChequebookBalance>) -> ChequebookCard {
254    let Some(cb) = cb else {
255        return ChequebookCard {
256            status: SwapStatus::Unknown,
257            total: "—".into(),
258            available: "—".into(),
259            available_pct: 0,
260            why: Some("/chequebook/balance not available yet".into()),
261        };
262    };
263    let zero = BigInt::from(0);
264    let total = &cb.total_balance;
265    let avail = &cb.available_balance;
266    let total_str = format_plur(total);
267    let avail_str = format_plur(avail);
268    if total == &zero {
269        return ChequebookCard {
270            status: SwapStatus::Empty,
271            total: total_str,
272            available: avail_str,
273            available_pct: 0,
274            why: Some("chequebook holds 0 BZZ — fund it to send cheques.".into()),
275        };
276    }
277    let pct = pct_of(avail, total);
278    let (status, why) = if pct < 20 {
279        (
280            SwapStatus::Tight,
281            Some(format!(
282                "only {pct}% available — most BZZ is tied up in unsettled debt."
283            )),
284        )
285    } else {
286        (SwapStatus::Healthy, None)
287    };
288    ChequebookCard {
289        status,
290        total: total_str,
291        available: avail_str,
292        available_pct: pct,
293        why,
294    }
295}
296
297fn cheque_rows_for(last_received: &[LastCheque]) -> Vec<CheckRow> {
298    let mut rows: Vec<CheckRow> = last_received
299        .iter()
300        .map(|lc| {
301            let payout_bi = lc.last_received.as_ref().and_then(|c| c.payout.as_ref());
302            let (payout, never) = match payout_bi {
303                Some(p) => (format_plur(p), false),
304                None => ("—".into(), true),
305            };
306            CheckRow {
307                peer_short: short_peer(&lc.peer),
308                payout,
309                never,
310            }
311        })
312        .collect();
313    // Sort: peers with cheques first, by payout descending (string-sort is OK
314    // because format_plur produces fixed-decimal `BZZ x.xxxx` form). Peers with
315    // no cheque sink to the bottom but stay visible.
316    rows.sort_by(|a, b| match (a.never, b.never) {
317        (false, true) => std::cmp::Ordering::Less,
318        (true, false) => std::cmp::Ordering::Greater,
319        _ => b.payout.cmp(&a.payout),
320    });
321    rows
322}
323
324fn settlement_rows_for(s: Option<&Settlements>) -> Vec<SettlementRow> {
325    let Some(s) = s else { return Vec::new() };
326    // Sort the raw settlements by |received - sent| descending so the
327    // most out-of-balance peers float to the top — that's where cashout
328    // pressure shows up first.
329    let mut sorted: Vec<&Settlement> = s.settlements.iter().collect();
330    sorted.sort_by_key(|s| std::cmp::Reverse(abs_net(s)));
331    sorted.into_iter().map(settlement_row).collect()
332}
333
334fn abs_net(s: &Settlement) -> BigInt {
335    let zero = BigInt::from(0);
336    let recv = s.received.as_ref().unwrap_or(&zero);
337    let sent = s.sent.as_ref().unwrap_or(&zero);
338    let net = recv - sent;
339    if net < zero { -net } else { net }
340}
341
342fn settlement_row(s: &Settlement) -> SettlementRow {
343    let zero = BigInt::from(0);
344    let recv = s.received.as_ref().unwrap_or(&zero);
345    let sent = s.sent.as_ref().unwrap_or(&zero);
346    let net_bi = recv - sent;
347    let net = format_plur_signed(&net_bi);
348    // Flag peers >0.5 BZZ out of balance (5 * 10^15 PLUR).
349    let half_bzz = BigInt::from(5_000_000_000_000_000u64);
350    let abs = if net_bi < BigInt::from(0) {
351        -net_bi
352    } else {
353        net_bi
354    };
355    let net_flagged = abs > half_bzz;
356    SettlementRow {
357        peer_short: short_peer(&s.peer),
358        received: format_plur(recv),
359        sent: format_plur(sent),
360        net,
361        net_flagged,
362    }
363}
364
365/// Format a PLUR amount as `BZZ x.xxxx` with 4 fractional digits.
366/// Bee's PLUR has 16 decimals; we render the top 4 digits of the
367/// fractional part so 0.0001 BZZ is the smallest visible unit, which
368/// is finer than any realistic per-cheque amount.
369pub fn format_plur(plur: &BigInt) -> String {
370    format_plur_inner(plur, false)
371}
372
373fn format_plur_signed(plur: &BigInt) -> String {
374    format_plur_inner(plur, true)
375}
376
377fn format_plur_inner(plur: &BigInt, signed: bool) -> String {
378    let zero = BigInt::from(0);
379    let neg = plur < &zero;
380    let abs = if neg { -plur.clone() } else { plur.clone() };
381    let scale = BigInt::from(10u64).pow(16);
382    let whole = &abs / &scale;
383    let frac = &abs % &scale;
384    // Take top 4 digits: divide by 10^12.
385    let frac_4 = &frac / BigInt::from(10u64).pow(12);
386    let sign = if neg {
387        "-"
388    } else if signed {
389        "+"
390    } else {
391        ""
392    };
393    format!("{sign}BZZ {whole}.{frac_4:0>4}")
394}
395
396fn pct_of(num: &BigInt, denom: &BigInt) -> u32 {
397    let zero = BigInt::from(0);
398    if denom == &zero {
399        return 0;
400    }
401    // (num * 100) / denom, clamped to 100.
402    let scaled = num * BigInt::from(100);
403    let q = &scaled / denom;
404    // Convert to u32 best-effort; in practice 0..=100.
405    let q_str = q.to_string();
406    let q_u: u128 = q_str.parse().unwrap_or(0);
407    q_u.min(100) as u32
408}
409
410fn short_peer(p: &str) -> String {
411    let trimmed = p.trim_start_matches("0x");
412    if trimmed.len() > 10 {
413        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
414    } else {
415        trimmed.to_string()
416    }
417}
418
419impl Component for Swap {
420    fn update(&mut self, action: Action) -> Result<Option<Action>> {
421        if matches!(action, Action::Tick) {
422            self.pull_latest();
423        }
424        Ok(None)
425    }
426
427    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
428        let view = Self::view_for(
429            &self.snapshot,
430            self.market_rx.as_ref().map(|_| &self.market),
431        );
432
433        // Layout slots: header, optional market tile (3 rows when
434        // present), chequebook card, tables, footer. Computing slots
435        // up-front keeps the conditional in one place.
436        let mut constraints: Vec<Constraint> = vec![Constraint::Length(3)];
437        let market_present = view.market.is_some();
438        if market_present {
439            constraints.push(Constraint::Length(3));
440        }
441        constraints.push(Constraint::Length(5)); // chequebook card
442        constraints.push(Constraint::Min(0)); // tables
443        constraints.push(Constraint::Length(1)); // footer
444        let chunks = Layout::vertical(constraints).split(area);
445
446        let mut slot = 0usize;
447        let header_slot = chunks[slot];
448        slot += 1;
449        let market_slot = if market_present {
450            let s = chunks[slot];
451            slot += 1;
452            Some(s)
453        } else {
454            None
455        };
456        let card_slot = chunks[slot];
457        slot += 1;
458        let tables_slot = chunks[slot];
459        slot += 1;
460        let footer_slot = chunks[slot];
461
462        let t = theme::active();
463        // Header
464        let mut header_l1 = vec![Span::styled(
465            "SWAP / CHEQUES",
466            Style::default().add_modifier(Modifier::BOLD),
467        )];
468        if let Some(addr) = &self.snapshot.chequebook_address {
469            header_l1.push(Span::raw("   contract "));
470            header_l1.push(Span::styled(addr.clone(), Style::default().fg(t.dim)));
471        }
472        let header_l1 = Line::from(header_l1);
473        let mut header_l2 = Vec::new();
474        if let Some(err) = &self.snapshot.last_error {
475            let (color, msg) = theme::classify_header_error(err);
476            header_l2.push(Span::styled(msg, Style::default().fg(color)));
477        } else if !self.snapshot.is_loaded() {
478            header_l2.push(Span::styled(
479                format!("{} loading…", theme::spinner_glyph()),
480                Style::default().fg(t.dim),
481            ));
482        }
483        frame.render_widget(
484            Paragraph::new(vec![header_l1, Line::from(header_l2)])
485                .block(Block::default().borders(Borders::BOTTOM)),
486            header_slot,
487        );
488
489        // Market tile (xBZZ price + Gnosis basefee). Rendered only
490        // when [economics].enable_market_tile is on. Two-line tile:
491        // price + gas, with optional dim "stale" why-line under it.
492        if let (Some(rect), Some(tile)) = (market_slot, view.market.as_ref()) {
493            let prefix = if tile.cold_start {
494                format!("{} ", theme::spinner_glyph())
495            } else {
496                "  ".to_string()
497            };
498            let mut lines = vec![Line::from(vec![
499                Span::raw(prefix.clone()),
500                Span::styled("Market  ", Style::default().add_modifier(Modifier::BOLD)),
501                Span::raw(tile.price_line.clone()),
502                Span::raw("    "),
503                Span::raw(tile.gas_line.clone()),
504            ])];
505            if let Some(why) = &tile.stale_why {
506                lines.push(Line::from(vec![
507                    Span::raw("    └─ "),
508                    Span::styled(
509                        format!("stale: {why}"),
510                        Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
511                    ),
512                ]));
513            }
514            frame.render_widget(
515                Paragraph::new(lines).block(Block::default().borders(Borders::BOTTOM)),
516                rect,
517            );
518        }
519
520        // Chequebook card
521        let card = &view.card;
522        let mut card_lines = vec![
523            Line::from(vec![
524                Span::styled(
525                    "  Chequebook  ",
526                    Style::default().add_modifier(Modifier::BOLD),
527                ),
528                Span::styled(
529                    card.status.label(),
530                    Style::default()
531                        .fg(card.status.color())
532                        .add_modifier(Modifier::BOLD),
533                ),
534            ]),
535            Line::from(vec![
536                Span::raw(format!("    total      {}", card.total)),
537                Span::raw("   "),
538                Span::raw(format!("available  {}", card.available)),
539                Span::raw("   "),
540                Span::styled(
541                    format!("({}% available)", card.available_pct),
542                    Style::default().fg(t.dim),
543                ),
544            ]),
545        ];
546        if let Some(why) = &card.why {
547            card_lines.push(Line::from(vec![
548                Span::raw("    └─ "),
549                Span::styled(
550                    why.clone(),
551                    Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
552                ),
553            ]));
554        }
555        frame.render_widget(
556            Paragraph::new(card_lines).block(Block::default().borders(Borders::BOTTOM)),
557            card_slot,
558        );
559
560        // Tables stacked: cheques (top) + settlements (bottom)
561        let table_chunks =
562            Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
563                .split(tables_slot);
564
565        // Cheques table
566        let mut cheque_lines: Vec<Line> = vec![Line::from(Span::styled(
567            "  PEER          LAST RECEIVED",
568            Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
569        ))];
570        if view.cheques.is_empty() {
571            cheque_lines.push(Line::from(Span::styled(
572                "  (no peer cheques known yet)",
573                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
574            )));
575        } else {
576            for r in &view.cheques {
577                let payout_style = if r.never {
578                    Style::default().fg(t.dim)
579                } else {
580                    Style::default().fg(t.pass)
581                };
582                cheque_lines.push(Line::from(vec![
583                    Span::raw("  "),
584                    Span::raw(format!("{:<14}", r.peer_short)),
585                    Span::styled(r.payout.clone(), payout_style),
586                ]));
587            }
588        }
589        frame.render_widget(
590            Paragraph::new(cheque_lines).block(Block::default().borders(Borders::BOTTOM).title(
591                Span::styled(
592                    " last cheques ",
593                    Style::default().add_modifier(Modifier::BOLD),
594                ),
595            )),
596            table_chunks[0],
597        );
598
599        // Settlements table
600        let mut settle_lines: Vec<Line> = vec![Line::from(Span::styled(
601            "  PEER          RECEIVED              SENT                 NET",
602            Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
603        ))];
604        if let (Some(tr), Some(ts)) = (&view.time_total_received, &view.time_total_sent) {
605            settle_lines.push(Line::from(vec![Span::styled(
606                format!("  time-based totals — received {tr} · sent {ts}"),
607                Style::default().fg(t.dim),
608            )]));
609        }
610        if view.settlements.is_empty() {
611            settle_lines.push(Line::from(Span::styled(
612                "  (no peer settlements yet)",
613                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
614            )));
615        } else {
616            for r in &view.settlements {
617                let net_style = if r.net_flagged {
618                    Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
619                } else {
620                    Style::default().fg(t.dim)
621                };
622                settle_lines.push(Line::from(vec![
623                    Span::raw("  "),
624                    Span::raw(format!("{:<14}", r.peer_short)),
625                    Span::raw(format!("{:<22}", r.received)),
626                    Span::raw(format!("{:<21}", r.sent)),
627                    Span::styled(r.net.clone(), net_style),
628                ]));
629            }
630        }
631        frame.render_widget(
632            Paragraph::new(settle_lines).block(Block::default().title(Span::styled(
633                " settlements ",
634                Style::default().add_modifier(Modifier::BOLD),
635            ))),
636            table_chunks[1],
637        );
638
639        // Footer
640        frame.render_widget(
641            Paragraph::new(Line::from(vec![
642                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
643                Span::raw(" switch screen  "),
644                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
645                Span::raw(" help  "),
646                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
647                Span::raw(" quit  "),
648                Span::styled(" net ", Style::default().fg(t.fail)),
649                Span::raw(" out-of-balance peer (>0.5 BZZ) "),
650            ])),
651            footer_slot,
652        );
653
654        Ok(())
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn format_plur_zero() {
664        assert_eq!(format_plur(&BigInt::from(0)), "BZZ 0.0000");
665    }
666
667    #[test]
668    fn format_plur_one_bzz() {
669        let one = BigInt::from(10u64).pow(16);
670        assert_eq!(format_plur(&one), "BZZ 1.0000");
671    }
672
673    #[test]
674    fn format_plur_fractional() {
675        // 0.5 BZZ = 5 * 10^15 PLUR
676        let half = BigInt::from(5_000_000_000_000_000u64);
677        assert_eq!(format_plur(&half), "BZZ 0.5000");
678    }
679
680    #[test]
681    fn format_plur_signed_negative() {
682        let one = BigInt::from(10u64).pow(16);
683        assert_eq!(format_plur_signed(&-one), "-BZZ 1.0000");
684    }
685
686    #[test]
687    fn format_plur_signed_positive() {
688        let one = BigInt::from(10u64).pow(16);
689        assert_eq!(format_plur_signed(&one), "+BZZ 1.0000");
690    }
691
692    #[test]
693    fn pct_of_handles_zero_denom() {
694        assert_eq!(pct_of(&BigInt::from(10), &BigInt::from(0)), 0);
695    }
696
697    #[test]
698    fn pct_of_clamps_to_100() {
699        assert_eq!(pct_of(&BigInt::from(200), &BigInt::from(100)), 100);
700    }
701
702    #[test]
703    fn short_peer_truncates_long_overlay() {
704        let p = "0xabcdef0123456789abcdef0123456789";
705        let s = short_peer(p);
706        assert!(s.contains('…'));
707        assert!(s.starts_with("abcdef"));
708    }
709
710    #[test]
711    fn short_peer_passes_short_through() {
712        assert_eq!(short_peer("abcd"), "abcd");
713    }
714}