1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum SwapStatus {
41 Empty,
43 Healthy,
45 Tight,
48 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#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ChequebookCard {
74 pub status: SwapStatus,
75 pub total: String,
77 pub available: String,
78 pub available_pct: u32,
80 pub why: Option<String>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct CheckRow {
86 pub peer_short: String,
87 pub payout: String,
89 pub never: bool,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SettlementRow {
96 pub peer_short: String,
97 pub received: String,
98 pub sent: String,
99 pub net: String,
101 pub net_flagged: bool,
103}
104
105#[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 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 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 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 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
268pub 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 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 let scaled = num * BigInt::from(100);
306 let q = &scaled / denom;
307 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), Constraint::Length(5), Constraint::Min(0), Constraint::Length(1), ])
337 .split(area);
338
339 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 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 let table_chunks =
408 Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
409 .split(chunks[2]);
410
411 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 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 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 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}