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 pub market: Option<MarketTile>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct MarketTile {
129 pub price_line: String,
131 pub gas_line: String,
134 pub stale_why: Option<String>,
139 pub cold_start: bool,
142}
143
144pub struct Swap {
145 rx: watch::Receiver<SwapSnapshot>,
146 snapshot: SwapSnapshot,
147 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 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 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 pub fn view_for_no_market(snap: &SwapSnapshot) -> SwapView {
222 Self::view_for(snap, None)
223 }
224}
225
226fn 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 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 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 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
365pub 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 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 let scaled = num * BigInt::from(100);
403 let q = &scaled / denom;
404 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 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)); constraints.push(Constraint::Min(0)); constraints.push(Constraint::Length(1)); 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 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 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 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 let table_chunks =
562 Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
563 .split(tables_slot);
564
565 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 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 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 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}