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 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 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 let table_chunks =
417 Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
418 .split(chunks[2]);
419
420 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 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 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 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}