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::theme;
35use crate::watch::SwapSnapshot;
36
37use bee::debug::{ChequebookBalance, LastCheque, Settlement, Settlements};
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum SwapStatus {
42 Empty,
44 Healthy,
46 Tight,
49 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#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ChequebookCard {
75 pub status: SwapStatus,
76 pub total: String,
78 pub available: String,
79 pub available_pct: u32,
81 pub why: Option<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct CheckRow {
87 pub peer_short: String,
88 pub payout: String,
90 pub never: bool,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct SettlementRow {
97 pub peer_short: String,
98 pub received: String,
99 pub sent: String,
100 pub net: String,
102 pub net_flagged: bool,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SwapView {
109 pub card: ChequebookCard,
110 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 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 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 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 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
275pub 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 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 let scaled = num * BigInt::from(100);
313 let q = &scaled / denom;
314 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), Constraint::Length(5), Constraint::Min(0), Constraint::Length(1), ])
344 .split(area);
345
346 let t = theme::active();
347 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 "loading…",
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 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()
407 .fg(t.dim)
408 .add_modifier(Modifier::ITALIC),
409 ),
410 ]));
411 }
412 frame.render_widget(
413 Paragraph::new(card_lines).block(Block::default().borders(Borders::BOTTOM)),
414 chunks[1],
415 );
416
417 let table_chunks =
419 Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
420 .split(chunks[2]);
421
422 let mut cheque_lines: Vec<Line> = vec![Line::from(Span::styled(
424 " PEER LAST RECEIVED",
425 Style::default()
426 .fg(t.dim)
427 .add_modifier(Modifier::BOLD),
428 ))];
429 if view.cheques.is_empty() {
430 cheque_lines.push(Line::from(Span::styled(
431 " (no peer cheques known yet)",
432 Style::default()
433 .fg(t.dim)
434 .add_modifier(Modifier::ITALIC),
435 )));
436 } else {
437 for r in &view.cheques {
438 let payout_style = if r.never {
439 Style::default().fg(t.dim)
440 } else {
441 Style::default().fg(t.pass)
442 };
443 cheque_lines.push(Line::from(vec![
444 Span::raw(" "),
445 Span::raw(format!("{:<14}", r.peer_short)),
446 Span::styled(r.payout.clone(), payout_style),
447 ]));
448 }
449 }
450 frame.render_widget(
451 Paragraph::new(cheque_lines).block(
452 Block::default()
453 .borders(Borders::BOTTOM)
454 .title(Span::styled(
455 " last cheques ",
456 Style::default().add_modifier(Modifier::BOLD),
457 )),
458 ),
459 table_chunks[0],
460 );
461
462 let mut settle_lines: Vec<Line> = vec![Line::from(Span::styled(
464 " PEER RECEIVED SENT NET",
465 Style::default()
466 .fg(t.dim)
467 .add_modifier(Modifier::BOLD),
468 ))];
469 if let (Some(tr), Some(ts)) = (&view.time_total_received, &view.time_total_sent) {
470 settle_lines.push(Line::from(vec![Span::styled(
471 format!(" time-based totals — received {tr} · sent {ts}"),
472 Style::default().fg(t.dim),
473 )]));
474 }
475 if view.settlements.is_empty() {
476 settle_lines.push(Line::from(Span::styled(
477 " (no peer settlements yet)",
478 Style::default()
479 .fg(t.dim)
480 .add_modifier(Modifier::ITALIC),
481 )));
482 } else {
483 for r in &view.settlements {
484 let net_style = if r.net_flagged {
485 Style::default()
486 .fg(t.fail)
487 .add_modifier(Modifier::BOLD)
488 } else {
489 Style::default().fg(t.dim)
490 };
491 settle_lines.push(Line::from(vec![
492 Span::raw(" "),
493 Span::raw(format!("{:<14}", r.peer_short)),
494 Span::raw(format!("{:<22}", r.received)),
495 Span::raw(format!("{:<21}", r.sent)),
496 Span::styled(r.net.clone(), net_style),
497 ]));
498 }
499 }
500 frame.render_widget(
501 Paragraph::new(settle_lines).block(Block::default().title(Span::styled(
502 " settlements ",
503 Style::default().add_modifier(Modifier::BOLD),
504 ))),
505 table_chunks[1],
506 );
507
508 frame.render_widget(
510 Paragraph::new(Line::from(vec![
511 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
512 Span::raw(" switch screen "),
513 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
514 Span::raw(" quit "),
515 Span::styled(" net ", Style::default().fg(t.fail)),
516 Span::raw(" out-of-balance peer (>0.5 BZZ) "),
517 ])),
518 chunks[3],
519 );
520
521 Ok(())
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn format_plur_zero() {
531 assert_eq!(format_plur(&BigInt::from(0)), "BZZ 0.0000");
532 }
533
534 #[test]
535 fn format_plur_one_bzz() {
536 let one = BigInt::from(10u64).pow(16);
537 assert_eq!(format_plur(&one), "BZZ 1.0000");
538 }
539
540 #[test]
541 fn format_plur_fractional() {
542 let half = BigInt::from(5_000_000_000_000_000u64);
544 assert_eq!(format_plur(&half), "BZZ 0.5000");
545 }
546
547 #[test]
548 fn format_plur_signed_negative() {
549 let one = BigInt::from(10u64).pow(16);
550 assert_eq!(format_plur_signed(&-one), "-BZZ 1.0000");
551 }
552
553 #[test]
554 fn format_plur_signed_positive() {
555 let one = BigInt::from(10u64).pow(16);
556 assert_eq!(format_plur_signed(&one), "+BZZ 1.0000");
557 }
558
559 #[test]
560 fn pct_of_handles_zero_denom() {
561 assert_eq!(pct_of(&BigInt::from(10), &BigInt::from(0)), 0);
562 }
563
564 #[test]
565 fn pct_of_clamps_to_100() {
566 assert_eq!(pct_of(&BigInt::from(200), &BigInt::from(100)), 100);
567 }
568
569 #[test]
570 fn short_peer_truncates_long_overlay() {
571 let p = "0xabcdef0123456789abcdef0123456789";
572 let s = short_peer(p);
573 assert!(s.contains('…'));
574 assert!(s.starts_with("abcdef"));
575 }
576
577 #[test]
578 fn short_peer_passes_short_through() {
579 assert_eq!(short_peer("abcd"), "abcd");
580 }
581}