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(&self.snapshot, self.market_rx.as_ref().map(|_| &self.market));
429
430 let mut constraints: Vec<Constraint> = vec![Constraint::Length(3)];
434 let market_present = view.market.is_some();
435 if market_present {
436 constraints.push(Constraint::Length(3));
437 }
438 constraints.push(Constraint::Length(5)); constraints.push(Constraint::Min(0)); constraints.push(Constraint::Length(1)); let chunks = Layout::vertical(constraints).split(area);
442
443 let mut slot = 0usize;
444 let header_slot = chunks[slot];
445 slot += 1;
446 let market_slot = if market_present {
447 let s = chunks[slot];
448 slot += 1;
449 Some(s)
450 } else {
451 None
452 };
453 let card_slot = chunks[slot];
454 slot += 1;
455 let tables_slot = chunks[slot];
456 slot += 1;
457 let footer_slot = chunks[slot];
458
459 let t = theme::active();
460 let mut header_l1 = vec![Span::styled(
462 "SWAP / CHEQUES",
463 Style::default().add_modifier(Modifier::BOLD),
464 )];
465 if let Some(addr) = &self.snapshot.chequebook_address {
466 header_l1.push(Span::raw(" contract "));
467 header_l1.push(Span::styled(addr.clone(), Style::default().fg(t.dim)));
468 }
469 let header_l1 = Line::from(header_l1);
470 let mut header_l2 = Vec::new();
471 if let Some(err) = &self.snapshot.last_error {
472 let (color, msg) = theme::classify_header_error(err);
473 header_l2.push(Span::styled(msg, Style::default().fg(color)));
474 } else if !self.snapshot.is_loaded() {
475 header_l2.push(Span::styled(
476 format!("{} loading…", theme::spinner_glyph()),
477 Style::default().fg(t.dim),
478 ));
479 }
480 frame.render_widget(
481 Paragraph::new(vec![header_l1, Line::from(header_l2)])
482 .block(Block::default().borders(Borders::BOTTOM)),
483 header_slot,
484 );
485
486 if let (Some(rect), Some(tile)) = (market_slot, view.market.as_ref()) {
490 let prefix = if tile.cold_start {
491 format!("{} ", theme::spinner_glyph())
492 } else {
493 " ".to_string()
494 };
495 let mut lines = vec![
496 Line::from(vec![
497 Span::raw(prefix.clone()),
498 Span::styled(
499 "Market ",
500 Style::default().add_modifier(Modifier::BOLD),
501 ),
502 Span::raw(tile.price_line.clone()),
503 Span::raw(" "),
504 Span::raw(tile.gas_line.clone()),
505 ]),
506 ];
507 if let Some(why) = &tile.stale_why {
508 lines.push(Line::from(vec![
509 Span::raw(" └─ "),
510 Span::styled(
511 format!("stale: {why}"),
512 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
513 ),
514 ]));
515 }
516 frame.render_widget(
517 Paragraph::new(lines).block(Block::default().borders(Borders::BOTTOM)),
518 rect,
519 );
520 }
521
522 let card = &view.card;
524 let mut card_lines = vec![
525 Line::from(vec![
526 Span::styled(
527 " Chequebook ",
528 Style::default().add_modifier(Modifier::BOLD),
529 ),
530 Span::styled(
531 card.status.label(),
532 Style::default()
533 .fg(card.status.color())
534 .add_modifier(Modifier::BOLD),
535 ),
536 ]),
537 Line::from(vec![
538 Span::raw(format!(" total {}", card.total)),
539 Span::raw(" "),
540 Span::raw(format!("available {}", card.available)),
541 Span::raw(" "),
542 Span::styled(
543 format!("({}% available)", card.available_pct),
544 Style::default().fg(t.dim),
545 ),
546 ]),
547 ];
548 if let Some(why) = &card.why {
549 card_lines.push(Line::from(vec![
550 Span::raw(" └─ "),
551 Span::styled(
552 why.clone(),
553 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
554 ),
555 ]));
556 }
557 frame.render_widget(
558 Paragraph::new(card_lines).block(Block::default().borders(Borders::BOTTOM)),
559 card_slot,
560 );
561
562 let table_chunks =
564 Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
565 .split(tables_slot);
566
567 let mut cheque_lines: Vec<Line> = vec![Line::from(Span::styled(
569 " PEER LAST RECEIVED",
570 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
571 ))];
572 if view.cheques.is_empty() {
573 cheque_lines.push(Line::from(Span::styled(
574 " (no peer cheques known yet)",
575 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
576 )));
577 } else {
578 for r in &view.cheques {
579 let payout_style = if r.never {
580 Style::default().fg(t.dim)
581 } else {
582 Style::default().fg(t.pass)
583 };
584 cheque_lines.push(Line::from(vec![
585 Span::raw(" "),
586 Span::raw(format!("{:<14}", r.peer_short)),
587 Span::styled(r.payout.clone(), payout_style),
588 ]));
589 }
590 }
591 frame.render_widget(
592 Paragraph::new(cheque_lines).block(Block::default().borders(Borders::BOTTOM).title(
593 Span::styled(
594 " last cheques ",
595 Style::default().add_modifier(Modifier::BOLD),
596 ),
597 )),
598 table_chunks[0],
599 );
600
601 let mut settle_lines: Vec<Line> = vec![Line::from(Span::styled(
603 " PEER RECEIVED SENT NET",
604 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
605 ))];
606 if let (Some(tr), Some(ts)) = (&view.time_total_received, &view.time_total_sent) {
607 settle_lines.push(Line::from(vec![Span::styled(
608 format!(" time-based totals — received {tr} · sent {ts}"),
609 Style::default().fg(t.dim),
610 )]));
611 }
612 if view.settlements.is_empty() {
613 settle_lines.push(Line::from(Span::styled(
614 " (no peer settlements yet)",
615 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
616 )));
617 } else {
618 for r in &view.settlements {
619 let net_style = if r.net_flagged {
620 Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
621 } else {
622 Style::default().fg(t.dim)
623 };
624 settle_lines.push(Line::from(vec![
625 Span::raw(" "),
626 Span::raw(format!("{:<14}", r.peer_short)),
627 Span::raw(format!("{:<22}", r.received)),
628 Span::raw(format!("{:<21}", r.sent)),
629 Span::styled(r.net.clone(), net_style),
630 ]));
631 }
632 }
633 frame.render_widget(
634 Paragraph::new(settle_lines).block(Block::default().title(Span::styled(
635 " settlements ",
636 Style::default().add_modifier(Modifier::BOLD),
637 ))),
638 table_chunks[1],
639 );
640
641 frame.render_widget(
643 Paragraph::new(Line::from(vec![
644 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
645 Span::raw(" switch screen "),
646 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
647 Span::raw(" help "),
648 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
649 Span::raw(" quit "),
650 Span::styled(" net ", Style::default().fg(t.fail)),
651 Span::raw(" out-of-balance peer (>0.5 BZZ) "),
652 ])),
653 footer_slot,
654 );
655
656 Ok(())
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 #[test]
665 fn format_plur_zero() {
666 assert_eq!(format_plur(&BigInt::from(0)), "BZZ 0.0000");
667 }
668
669 #[test]
670 fn format_plur_one_bzz() {
671 let one = BigInt::from(10u64).pow(16);
672 assert_eq!(format_plur(&one), "BZZ 1.0000");
673 }
674
675 #[test]
676 fn format_plur_fractional() {
677 let half = BigInt::from(5_000_000_000_000_000u64);
679 assert_eq!(format_plur(&half), "BZZ 0.5000");
680 }
681
682 #[test]
683 fn format_plur_signed_negative() {
684 let one = BigInt::from(10u64).pow(16);
685 assert_eq!(format_plur_signed(&-one), "-BZZ 1.0000");
686 }
687
688 #[test]
689 fn format_plur_signed_positive() {
690 let one = BigInt::from(10u64).pow(16);
691 assert_eq!(format_plur_signed(&one), "+BZZ 1.0000");
692 }
693
694 #[test]
695 fn pct_of_handles_zero_denom() {
696 assert_eq!(pct_of(&BigInt::from(10), &BigInt::from(0)), 0);
697 }
698
699 #[test]
700 fn pct_of_clamps_to_100() {
701 assert_eq!(pct_of(&BigInt::from(200), &BigInt::from(100)), 100);
702 }
703
704 #[test]
705 fn short_peer_truncates_long_overlay() {
706 let p = "0xabcdef0123456789abcdef0123456789";
707 let s = short_peer(p);
708 assert!(s.contains('…'));
709 assert!(s.starts_with("abcdef"));
710 }
711
712 #[test]
713 fn short_peer_passes_short_through() {
714 assert_eq!(short_peer("abcd"), "abcd");
715 }
716}