From 377d7449273734acbce5c3dbb5ed3becae18f85c Mon Sep 17 00:00:00 2001 From: ungaro Date: Thu, 28 May 2026 19:31:55 -0400 Subject: [PATCH] feat(tui): update banner and end-to-end broadcast pipeline for Deposit/Withdraw (v0.2.4) --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/tui/app.rs | 641 +++++++++++++++++++++++++++++++++++++++++++++---- src/tui/ui.rs | 240 +++++++++++++++++- 4 files changed, 832 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa10a8f..2a43b4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3049,7 +3049,7 @@ dependencies = [ [[package]] name = "nest-cli" -version = "0.2.3" +version = "0.2.4" dependencies = [ "alloy", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 2a97d21..c653fc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nest-cli" -version = "0.2.3" +version = "0.2.4" edition = "2024" description = "CLI and TUI for Nest Vaults on Plume Network" license = "MIT" diff --git a/src/tui/app.rs b/src/tui/app.rs index 596cf3a..c97347d 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -10,16 +10,17 @@ use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; use crate::api::actions::EvmActionsClient; use crate::api::actions_types::{ - InstantRedeemLiquidityRequest, MintBuildTxRequest, UserChainAssetRequest, + InstantRedeemLiquidityRequest, MintBuildTxRequest, RedeemBuildTxRequest, UserChainAssetRequest, }; use crate::api::client::NestApiClient; use crate::api::types::{HistoryPoint, LiquidAsset, VaultBasic, VaultDetailed, VaultType}; use crate::chain::contracts::{Accountant, ERC20, NestVaultOft, RATE_DECIMALS}; use crate::chain::multicall::MulticallBuilder; -use crate::chain::provider::build_provider; +use crate::chain::provider::{build_provider, build_signing_provider}; use crate::chain::signer::build_signer; use crate::config::{AppConfig, rpc_url_for_chain}; use crate::tui::widgets::modal::{Modal, ModalAction}; +use crate::tx_bundle::{TxBundle, execute_bundle}; // --------------------------------------------------------------------------- // Tab indices @@ -73,6 +74,51 @@ pub enum PreviewState { Error(String), } +// --------------------------------------------------------------------------- +// Submit-state machine (v0.2.4): drives the Deposit/Withdraw broadcast pipeline +// --------------------------------------------------------------------------- + +#[derive(Default, Debug, Clone)] +pub enum SubmitState { + #[default] + Idle, + BuildingBundle, + AwaitingConfirm(TxBundle), + Broadcasting { + bundle: TxBundle, + progress: Vec, + }, + Done { + receipts: Vec, + }, + Failed { + error: String, + }, +} + +#[derive(Debug, Clone)] +pub enum TxProgress { + Pending, + Confirmed(TxResult), + Failed(String), +} + +#[derive(Debug, Clone)] +pub struct TxResult { + pub label: String, + pub tx_hash: String, + pub block_number: u64, + pub explorer: Option, +} + +/// User's binary choice in the submit-confirm modal. +/// `Cancel` is listed first so `Modal::new`'s items[0]-default selection is safe. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SubmitChoice { + Cancel, + Broadcast, +} + #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] pub enum DepositField { #[default] @@ -120,6 +166,8 @@ pub struct DepositForm { pub last_amount_change: Option, /// Set once a debounced preview has been spawned for the current amount. pub preview_pending: bool, + /// Submit/broadcast pipeline state (v0.2.4). + pub submit_state: SubmitState, } #[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] @@ -174,6 +222,8 @@ pub struct WithdrawForm { pub active_field: WithdrawField, pub status: String, pub validation_err: Option, + /// Submit/broadcast pipeline state (v0.2.4). + pub submit_state: SubmitState, } // --------------------------------------------------------------------------- @@ -191,6 +241,8 @@ pub enum ModalKind { WithdrawVaultPicker, /// Vault picker opened from the History tab via `v`. HistoryVaultPicker, + /// Confirm dialog before broadcasting a built tx bundle (v0.2.4). + SubmitConfirm, } /// The discrete withdraw modes the user can pick from the Withdraw form. @@ -209,6 +261,8 @@ pub enum ModalItem { Chain(u64), Asset(Box), WithdrawMode(WithdrawModeKind), + /// Cancel / Broadcast choice in the submit-confirm modal (v0.2.4). + SubmitChoice(SubmitChoice), } // --------------------------------------------------------------------------- @@ -223,6 +277,14 @@ pub enum FetchMsg { WithdrawShareBalance(BalanceState), WithdrawInstantLiquidity(BalanceState), WithdrawClaimable(BalanceState), + /// Whole-state transition for the deposit submit pipeline (v0.2.4). + DepositSubmitState(SubmitState), + /// Streamed per-transaction progress while broadcasting a deposit bundle. + DepositSubmitProgress(usize, TxProgress), + /// Whole-state transition for the withdraw submit pipeline (v0.2.4). + WithdrawSubmitState(SubmitState), + /// Streamed per-transaction progress while broadcasting a withdraw bundle. + WithdrawSubmitProgress(usize, TxProgress), } // --------------------------------------------------------------------------- @@ -314,6 +376,11 @@ pub struct App { // Async fetcher plumbing. pub fetch_tx: UnboundedSender, pub fetch_rx: UnboundedReceiver, + + // Update banner (v0.2.4). Cached once at startup so we don't re-read + // the disk cache on every frame. + pub cached_latest: Option, + pub banner_dismissed: bool, } impl App { @@ -350,6 +417,8 @@ impl App { modal: None, fetch_tx, fetch_rx, + cached_latest: crate::version_check::cached_latest(), + banner_dismissed: false, }; app.vaults_table_state.select(Some(0)); app.positions_table_state.select(Some(0)); @@ -610,6 +679,18 @@ impl App { FetchMsg::WithdrawShareBalance(s) => self.withdraw_form.share_balance = s, FetchMsg::WithdrawInstantLiquidity(s) => self.withdraw_form.instant_liquidity = s, FetchMsg::WithdrawClaimable(s) => self.withdraw_form.claimable = s, + FetchMsg::DepositSubmitState(s) => { + apply_submit_state(&mut self.deposit_form.submit_state, s, &mut self.modal); + } + FetchMsg::DepositSubmitProgress(i, p) => { + apply_submit_progress(&mut self.deposit_form.submit_state, i, p); + } + FetchMsg::WithdrawSubmitState(s) => { + apply_submit_state(&mut self.withdraw_form.submit_state, s, &mut self.modal); + } + FetchMsg::WithdrawSubmitProgress(i, p) => { + apply_submit_progress(&mut self.withdraw_form.submit_state, i, p); + } } } @@ -709,6 +790,13 @@ impl App { } } + // Esc also dismisses the update banner for the rest of the session. + // (Other Esc handlers above — tab-back, close modal, exit filter — + // already ran; the banner dismissal is a no-op no-cost side effect.) + if key.code == KeyCode::Esc && self.cached_latest.is_some() && !self.banner_dismissed { + self.banner_dismissed = true; + } + false } @@ -805,6 +893,48 @@ impl App { self.history_offset_days = 0; self.load_history().await; } + (ModalKind::SubmitConfirm, ModalItem::SubmitChoice(choice)) => { + self.commit_submit_choice(choice); + } + _ => {} + } + } + + /// Handle the user's Cancel/Broadcast pick on the submit-confirm modal. + /// Dispatches by the currently active tab; the relevant form must already + /// be in `AwaitingConfirm(bundle)`. + fn commit_submit_choice(&mut self, choice: SubmitChoice) { + match self.active_tab { + TAB_DEPOSIT => match (choice, std::mem::take(&mut self.deposit_form.submit_state)) { + (SubmitChoice::Cancel, _) => { + self.deposit_form.submit_state = SubmitState::Idle; + } + (SubmitChoice::Broadcast, SubmitState::AwaitingConfirm(bundle)) => { + let progress = vec![TxProgress::Pending; bundle.transactions.len()]; + let bundle_for_task = bundle.clone(); + self.deposit_form.submit_state = SubmitState::Broadcasting { bundle, progress }; + self.spawn_broadcast(bundle_for_task, /* withdraw = */ false); + } + (SubmitChoice::Broadcast, other) => { + // Restore prior state if we got here without an AwaitingConfirm. + self.deposit_form.submit_state = other; + } + }, + TAB_WITHDRAW => match (choice, std::mem::take(&mut self.withdraw_form.submit_state)) { + (SubmitChoice::Cancel, _) => { + self.withdraw_form.submit_state = SubmitState::Idle; + } + (SubmitChoice::Broadcast, SubmitState::AwaitingConfirm(bundle)) => { + let progress = vec![TxProgress::Pending; bundle.transactions.len()]; + let bundle_for_task = bundle.clone(); + self.withdraw_form.submit_state = + SubmitState::Broadcasting { bundle, progress }; + self.spawn_broadcast(bundle_for_task, /* withdraw = */ true); + } + (SubmitChoice::Broadcast, other) => { + self.withdraw_form.submit_state = other; + } + }, _ => {} } } @@ -983,6 +1113,18 @@ impl App { match (key.code, key.modifiers) { (KeyCode::Esc, _) => { + // If we're showing a Done/Failed result body, Esc returns to the + // editable form instead of bailing back to Vaults. + if matches!( + self.deposit_form.submit_state, + SubmitState::Done { .. } | SubmitState::Failed { .. } + ) { + self.deposit_form.submit_state = SubmitState::Idle; + self.deposit_form.amount = tui_input::Input::default(); + self.deposit_form.last_amount_change = None; + self.deposit_form.validation_err = None; + return; + } self.active_tab = TAB_VAULTS; } (KeyCode::Down, _) => { @@ -1148,6 +1290,13 @@ impl App { } fn submit_deposit(&mut self) { + // Don't re-enter while a submit is in flight. + if !matches!( + self.deposit_form.submit_state, + SubmitState::Idle | SubmitState::Failed { .. } | SubmitState::Done { .. } + ) { + return; + } let Some(ref vault) = self.deposit_form.vault else { self.deposit_form.status = "Pick a vault first".to_string(); return; @@ -1160,21 +1309,47 @@ impl App { self.deposit_form.status = "Pick an asset first".to_string(); return; }; - if let Err(e) = self.validate_deposit_amount() { - self.deposit_form.validation_err = Some(e.message()); - self.deposit_form.status = format!("Invalid amount: {}", e.message()); + let raw = match self.validate_deposit_amount() { + Ok(r) => r, + Err(e) => { + self.deposit_form.validation_err = Some(e.message()); + self.deposit_form.status = format!("Invalid amount: {}", e.message()); + return; + } + }; + if self.cfg.private_key.is_none() { + self.deposit_form.status = "No PRIVATE_KEY configured — set one to broadcast".into(); return; } - let chain_name = crate::config::chain_config(chain) - .map(|c| c.name.to_lowercase()) - .unwrap_or_else(|| chain.to_string()); - self.deposit_form.status = format!( - "Run: nest deposit --vault {} --asset {} --amount {} --chain {}", - vault.basic.slug, - asset.contract_address, - self.deposit_form.amount.value(), - chain_name - ); + let Some(wallet) = self.wallet_address.clone() else { + self.deposit_form.status = "No wallet address resolved from PRIVATE_KEY".into(); + return; + }; + + let slug = vault.basic.slug.clone(); + let req = MintBuildTxRequest { + deposit_asset: asset.contract_address.clone(), + deposit_amount: raw.to_string(), + chain_id: chain, + recipient: wallet, + skip_simulation: Some(true), + }; + + self.deposit_form.submit_state = SubmitState::BuildingBundle; + self.deposit_form.status = "Building transaction…".to_string(); + + let actions_url = self.cfg.evm_actions_api_url.clone(); + let tx = self.fetch_tx.clone(); + tokio::spawn(async move { + let client = EvmActionsClient::new(&actions_url); + let state = match client.mint_build_tx(&slug, &req).await { + Ok(bundle) => SubmitState::AwaitingConfirm(bundle), + Err(e) => SubmitState::Failed { + error: e.to_string(), + }, + }; + let _ = tx.send(FetchMsg::DepositSubmitState(state)); + }); } // ===== Withdraw form ===== @@ -1184,6 +1359,15 @@ impl App { match (key.code, key.modifiers) { (KeyCode::Esc, _) => { + if matches!( + self.withdraw_form.submit_state, + SubmitState::Done { .. } | SubmitState::Failed { .. } + ) { + self.withdraw_form.submit_state = SubmitState::Idle; + self.withdraw_form.shares = tui_input::Input::default(); + self.withdraw_form.validation_err = None; + return; + } self.active_tab = TAB_PORTFOLIO; } (KeyCode::Down, _) => { @@ -1340,6 +1524,13 @@ impl App { } fn submit_withdraw(&mut self) { + // Don't re-enter while a submit is in flight. + if !matches!( + self.withdraw_form.submit_state, + SubmitState::Idle | SubmitState::Failed { .. } | SubmitState::Done { .. } + ) { + return; + } let Some(ref vault) = self.withdraw_form.vault else { self.withdraw_form.status = "Pick a vault first".to_string(); return; @@ -1356,35 +1547,153 @@ impl App { self.withdraw_form.status = "Pick a mode first".to_string(); return; }; - if let Err(e) = self.validate_withdraw_shares() { - self.withdraw_form.validation_err = Some(e.message()); - self.withdraw_form.status = format!("Invalid shares: {}", e.message()); + let raw = match self.validate_withdraw_shares() { + Ok(r) => r, + Err(e) => { + self.withdraw_form.validation_err = Some(e.message()); + self.withdraw_form.status = format!("Invalid shares: {}", e.message()); + return; + } + }; + + // v0.2.4: only Request mode is wired through the broadcast pipeline. + // Instant / Claim still surface the CLI-hint string for now. + if !matches!(mode, WithdrawModeKind::Request) { + let chain_name = crate::config::chain_config(chain) + .map(|c| c.name.to_lowercase()) + .unwrap_or_else(|| chain.to_string()); + self.withdraw_form.status = match mode { + WithdrawModeKind::Instant => format!( + "Run: nest instant-redeem submit --vault {} --shares {} --redemption-asset {} --chain {}", + vault.basic.slug, + self.withdraw_form.shares.value(), + asset.contract_address, + chain_name, + ), + WithdrawModeKind::Claim => format!( + "Run: nest claim submit --vault {} --redemption-asset {} --chain {}", + vault.basic.slug, asset.contract_address, chain_name, + ), + WithdrawModeKind::Request => unreachable!(), + }; + return; + } + + if self.cfg.private_key.is_none() { + self.withdraw_form.status = "No PRIVATE_KEY configured — set one to broadcast".into(); return; } - let chain_name = crate::config::chain_config(chain) - .map(|c| c.name.to_lowercase()) - .unwrap_or_else(|| chain.to_string()); - let cmd = match mode { - WithdrawModeKind::Request => format!( - "Run: nest withdraw --vault {} --shares {} --redemption-asset {} --chain {}", - vault.basic.slug, - self.withdraw_form.shares.value(), - asset.contract_address, - chain_name, - ), - WithdrawModeKind::Instant => format!( - "Run: nest instant-redeem submit --vault {} --shares {} --redemption-asset {} --chain {}", - vault.basic.slug, - self.withdraw_form.shares.value(), - asset.contract_address, - chain_name, - ), - WithdrawModeKind::Claim => format!( - "Run: nest claim submit --vault {} --redemption-asset {} --chain {}", - vault.basic.slug, asset.contract_address, chain_name, - ), + let Some(wallet) = self.wallet_address.clone() else { + self.withdraw_form.status = "No wallet address resolved from PRIVATE_KEY".into(); + return; + }; + + let slug = vault.basic.slug.clone(); + let req = RedeemBuildTxRequest { + redemption_asset: asset.contract_address.clone(), + share_amount: raw.to_string(), + chain_id: chain, + user: wallet, + skip_simulation: Some(true), + }; + + self.withdraw_form.submit_state = SubmitState::BuildingBundle; + self.withdraw_form.status = "Building transaction…".to_string(); + + let actions_url = self.cfg.evm_actions_api_url.clone(); + let tx = self.fetch_tx.clone(); + tokio::spawn(async move { + let client = EvmActionsClient::new(&actions_url); + let state = match client.redeem_build_tx(&slug, &req).await { + Ok(bundle) => SubmitState::AwaitingConfirm(bundle), + Err(e) => SubmitState::Failed { + error: e.to_string(), + }, + }; + let _ = tx.send(FetchMsg::WithdrawSubmitState(state)); + }); + } + + /// Spawn the broadcast task that streams per-tx progress messages. + /// Each `BundleTx` is sent as its own one-tx bundle so we can stream after + /// each receipt without reimplementing `execute_bundle`. + fn spawn_broadcast(&self, bundle: TxBundle, withdraw: bool) { + let pk = match self.cfg.private_key.clone() { + Some(p) => p, + None => { + let state = SubmitState::Failed { + error: "no PRIVATE_KEY".to_string(), + }; + let _ = self.fetch_tx.send(if withdraw { + FetchMsg::WithdrawSubmitState(state) + } else { + FetchMsg::DepositSubmitState(state) + }); + return; + } }; - self.withdraw_form.status = cmd; + let tx = self.fetch_tx.clone(); + tokio::spawn(async move { + let signer = match build_signer(&pk) { + Ok(s) => s, + Err(e) => { + let state = SubmitState::Failed { + error: e.to_string(), + }; + let _ = tx.send(if withdraw { + FetchMsg::WithdrawSubmitState(state) + } else { + FetchMsg::DepositSubmitState(state) + }); + return; + } + }; + let rpc = match rpc_url_for_chain(bundle.chain_id) { + Ok(s) => s, + Err(e) => { + let state = SubmitState::Failed { + error: e.to_string(), + }; + let _ = tx.send(if withdraw { + FetchMsg::WithdrawSubmitState(state) + } else { + FetchMsg::DepositSubmitState(state) + }); + return; + } + }; + let provider = build_signing_provider(&rpc, signer); + for (i, bt) in bundle.transactions.iter().enumerate() { + let _ = tx.send(if withdraw { + FetchMsg::WithdrawSubmitProgress(i, TxProgress::Pending) + } else { + FetchMsg::DepositSubmitProgress(i, TxProgress::Pending) + }); + let single = vec![bt.clone()]; + let progress = match execute_bundle(&provider, bundle.chain_id, &single).await { + Ok(receipts) if !receipts.is_empty() => { + let r = &receipts[0]; + TxProgress::Confirmed(TxResult { + label: bt.label.clone(), + tx_hash: r.tx_hash.clone(), + block_number: r.block_number, + explorer: r.explorer.clone(), + }) + } + Ok(_) => TxProgress::Failed("no receipt".into()), + Err(e) => TxProgress::Failed(e.to_string()), + }; + let is_fail = matches!(progress, TxProgress::Failed(_)); + let _ = tx.send(if withdraw { + FetchMsg::WithdrawSubmitProgress(i, progress) + } else { + FetchMsg::DepositSubmitProgress(i, progress) + }); + if is_fail { + return; + } + } + }); } // ===== Async fetchers ===== @@ -1566,6 +1875,80 @@ impl App { // Helpers // --------------------------------------------------------------------------- +/// Apply a whole-state submit transition. If the new state is +/// `AwaitingConfirm(bundle)`, open the confirm modal with Cancel listed first +/// so the modal's items[0] default lands on Cancel. +fn apply_submit_state( + slot: &mut SubmitState, + new: SubmitState, + modal: &mut Option<(ModalKind, Modal)>, +) { + if let SubmitState::AwaitingConfirm(ref bundle) = new { + let title = format!( + "Submit: {} tx{} to chain {}", + bundle.transactions.len(), + if bundle.transactions.len() == 1 { + "" + } else { + "s" + }, + bundle.chain_id + ); + let items = vec![ + ModalItem::SubmitChoice(SubmitChoice::Cancel), + ModalItem::SubmitChoice(SubmitChoice::Broadcast), + ]; + let m = Modal::new(title, items, |it: &ModalItem| match it { + ModalItem::SubmitChoice(SubmitChoice::Cancel) => { + ratatui::text::Line::from(vec![ratatui::text::Span::raw(" Cancel")]) + } + ModalItem::SubmitChoice(SubmitChoice::Broadcast) => { + ratatui::text::Line::from(vec![ratatui::text::Span::styled( + " Broadcast", + ratatui::style::Style::new() + .fg(ratatui::style::Color::Red) + .add_modifier(ratatui::style::Modifier::BOLD), + )]) + } + _ => ratatui::text::Line::from(""), + }); + *modal = Some((ModalKind::SubmitConfirm, m)); + } + *slot = new; +} + +/// Mutate the per-tx progress slot inside a `Broadcasting` state and transition +/// to `Done` if every entry is Confirmed, or `Failed` if any is Failed. +fn apply_submit_progress(slot: &mut SubmitState, i: usize, p: TxProgress) { + if let SubmitState::Broadcasting { progress, .. } = slot { + if let Some(entry) = progress.get_mut(i) { + *entry = p; + } + // Check whether we should transition out of Broadcasting. + let any_failed = progress.iter().find_map(|e| match e { + TxProgress::Failed(s) => Some(s.clone()), + _ => None, + }); + if let Some(error) = any_failed { + *slot = SubmitState::Failed { error }; + return; + } + let all_confirmed = progress + .iter() + .all(|e| matches!(e, TxProgress::Confirmed(_))); + if all_confirmed { + let receipts: Vec = progress + .iter() + .filter_map(|e| match e { + TxProgress::Confirmed(r) => Some(r.clone()), + _ => None, + }) + .collect(); + *slot = SubmitState::Done { receipts }; + } + } +} + fn move_selection_down(state: &mut TableState, count: usize) { if count == 0 { return; @@ -1940,4 +2323,180 @@ mod tests { .await; assert_eq!(app.withdraw_form.mode, Some(WithdrawModeKind::Instant)); } + + // ===== v0.2.4: submit-state machine ===== + + fn mk_bundle(slug: &str, txs: usize) -> TxBundle { + let transactions = (0..txs) + .map(|i| crate::tx_bundle::BundleTx { + label: format!("tx{i}"), + to: format!("0x{:040x}", i + 1), + data: "0x".to_string(), + value: "0".to_string(), + description: format!("desc{i}"), + }) + .collect(); + TxBundle::local(slug, 98866, transactions) + } + + fn mk_result(label: &str, hash: &str, block: u64) -> TxResult { + TxResult { + label: label.to_string(), + tx_hash: hash.to_string(), + block_number: block, + explorer: Some(format!("https://explorer.example/tx/{hash}")), + } + } + + #[test] + fn submit_state_default_is_idle() { + let s = SubmitState::default(); + assert!(matches!(s, SubmitState::Idle)); + } + + #[test] + fn confirm_modal_defaults_to_cancel() { + // Build the confirm modal the way `apply_submit_state` does and assert + // the initial selection is Cancel (items[0]) — not Broadcast. + let bundle = mk_bundle("nest-foo", 2); + let mut slot = SubmitState::Idle; + let mut modal: Option<(ModalKind, Modal)> = None; + apply_submit_state( + &mut slot, + SubmitState::AwaitingConfirm(bundle.clone()), + &mut modal, + ); + assert!(matches!(slot, SubmitState::AwaitingConfirm(_))); + let (kind, m) = modal.expect("confirm modal should be open"); + assert_eq!(kind, ModalKind::SubmitConfirm); + match m.selected() { + Some(ModalItem::SubmitChoice(c)) => { + assert_eq!(*c, SubmitChoice::Cancel); + } + other => panic!("expected SubmitChoice::Cancel, got {:?}", other.is_some()), + } + } + + #[test] + fn apply_submit_progress_transitions_to_done_when_all_confirmed() { + let bundle = mk_bundle("nest-foo", 2); + let mut state = SubmitState::Broadcasting { + bundle: bundle.clone(), + progress: vec![TxProgress::Pending, TxProgress::Pending], + }; + apply_submit_progress( + &mut state, + 0, + TxProgress::Confirmed(mk_result("tx0", "0xaaaa", 100)), + ); + assert!(matches!(state, SubmitState::Broadcasting { .. })); + apply_submit_progress( + &mut state, + 1, + TxProgress::Confirmed(mk_result("tx1", "0xbbbb", 101)), + ); + match state { + SubmitState::Done { ref receipts } => { + assert_eq!(receipts.len(), 2); + assert_eq!(receipts[0].tx_hash, "0xaaaa"); + assert_eq!(receipts[1].block_number, 101); + } + other => panic!("expected Done, got {other:?}"), + } + } + + #[test] + fn apply_submit_progress_transitions_to_failed_on_any_failure() { + let bundle = mk_bundle("nest-foo", 2); + let mut state = SubmitState::Broadcasting { + bundle, + progress: vec![TxProgress::Pending, TxProgress::Pending], + }; + apply_submit_progress(&mut state, 0, TxProgress::Failed("boom".into())); + match state { + SubmitState::Failed { ref error } => assert_eq!(error, "boom"), + other => panic!("expected Failed, got {other:?}"), + } + } + + #[test] + fn drain_fetches_routes_deposit_submit_progress() { + let mut app = mk_app(); + let bundle = mk_bundle("nest-foo", 2); + app.deposit_form.submit_state = SubmitState::Broadcasting { + bundle, + progress: vec![TxProgress::Pending, TxProgress::Pending], + }; + app.fetch_tx + .send(FetchMsg::DepositSubmitProgress( + 0, + TxProgress::Confirmed(mk_result("tx0", "0xaaaa", 1)), + )) + .unwrap(); + app.fetch_tx + .send(FetchMsg::DepositSubmitProgress( + 1, + TxProgress::Confirmed(mk_result("tx1", "0xbbbb", 2)), + )) + .unwrap(); + app.drain_fetches(); + match app.deposit_form.submit_state { + SubmitState::Done { ref receipts } => assert_eq!(receipts.len(), 2), + ref other => panic!("expected Done, got {other:?}"), + } + } + + #[test] + fn drain_fetches_routes_withdraw_submit_progress() { + let mut app = mk_app(); + let bundle = mk_bundle("nest-foo", 1); + app.withdraw_form.submit_state = SubmitState::Broadcasting { + bundle, + progress: vec![TxProgress::Pending], + }; + app.fetch_tx + .send(FetchMsg::WithdrawSubmitProgress( + 0, + TxProgress::Confirmed(mk_result("redeem", "0xcccc", 9)), + )) + .unwrap(); + app.drain_fetches(); + match app.withdraw_form.submit_state { + SubmitState::Done { ref receipts } => { + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].label, "redeem"); + } + ref other => panic!("expected Done, got {other:?}"), + } + } + + #[test] + fn drain_fetches_routes_awaiting_confirm_opens_modal() { + let mut app = mk_app(); + let bundle = mk_bundle("nest-foo", 1); + app.fetch_tx + .send(FetchMsg::DepositSubmitState(SubmitState::AwaitingConfirm( + bundle, + ))) + .unwrap(); + app.drain_fetches(); + assert!(matches!( + app.deposit_form.submit_state, + SubmitState::AwaitingConfirm(_) + )); + let (kind, _) = app.modal.as_ref().expect("modal opened"); + assert_eq!(*kind, ModalKind::SubmitConfirm); + } + + #[test] + fn formatted_result_line_includes_hash_block_and_explorer() { + let r = mk_result("deposit", "0xabcdef0123456789", 70741864); + let line = crate::tui::ui::format_tx_result_line(0, 2, &r); + let txt: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(txt.contains("[1/2]")); + assert!(txt.contains("deposit")); + assert!(txt.contains("0xabcd")); + assert!(txt.contains("block 70741864")); + assert!(txt.contains("https://explorer.example/tx/0xabcdef0123456789")); + } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index bda5e55..35d71f3 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -7,18 +7,39 @@ use ratatui::widgets::{Axis, Block, Cell, Chart, Dataset, GraphType, Paragraph, use throbber_widgets_tui::Throbber; use super::app::{ - App, BalanceState, DepositField, PreviewState, TAB_DEPOSIT, TAB_HISTORY, TAB_PORTFOLIO, - TAB_TITLES, TAB_VAULTS, TAB_WITHDRAW, WithdrawField, WithdrawModeKind, format_raw_amount, - short_addr, + App, BalanceState, DepositField, PreviewState, SubmitState, TAB_DEPOSIT, TAB_HISTORY, + TAB_PORTFOLIO, TAB_TITLES, TAB_VAULTS, TAB_WITHDRAW, TxProgress, TxResult, WithdrawField, + WithdrawModeKind, format_raw_amount, short_addr, }; pub fn render(frame: &mut Frame, app: &mut App) { - let [tabs_area, content_area, status_area] = Layout::vertical([ - Constraint::Length(3), - Constraint::Fill(1), - Constraint::Length(1), - ]) - .areas(frame.area()); + let show_banner = app.cached_latest.is_some() && !app.banner_dismissed; + let (banner_area, tabs_area, content_area, status_area) = if show_banner { + let [banner, tabs, content, status] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(frame.area()); + (Some(banner), tabs, content, status) + } else { + let [tabs, content, status] = Layout::vertical([ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(frame.area()); + (None, tabs, content, status) + }; + + // -- Update banner (v0.2.4) -- + if let (Some(area), Some(latest)) = (banner_area, app.cached_latest.as_ref()) { + let txt = + format!(" \u{25B2} nest v{latest} is available — run `nest update` (Esc to dismiss)"); + let banner = Paragraph::new(txt).style(Style::new().fg(Color::Black).bg(Color::Yellow)); + frame.render_widget(banner, area); + } // -- Tabs bar -- let tabs = Tabs::new(TAB_TITLES.iter().copied()) @@ -430,6 +451,25 @@ fn render_deposit_form(frame: &mut Frame, app: &mut App, area: ratatui::layout:: let [form_area, status_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).areas(area); + // Done / Failed states swap the entire body for a result view. + match &app.deposit_form.submit_state { + SubmitState::Done { receipts } => { + render_result_body(frame, form_area, " Deposit — Done ", receipts, None); + let status = Paragraph::new(app.deposit_form.status.as_str()) + .block(Block::bordered().title(" Status ")); + frame.render_widget(status, status_area); + return; + } + SubmitState::Failed { error } => { + render_failure_body(frame, form_area, " Deposit — Failed ", error); + let status = Paragraph::new(app.deposit_form.status.as_str()) + .block(Block::bordered().title(" Status ")); + frame.render_widget(status, status_area); + return; + } + _ => {} + } + let active = app.deposit_form.active_field; let mut lines: Vec = vec![ field_row( @@ -501,6 +541,9 @@ fn render_deposit_form(frame: &mut Frame, app: &mut App, area: ratatui::layout:: // Submit row. lines.push(submit_row("Submit", active == DepositField::Submit)); + // Submit-pipeline progress / preview lines (v0.2.4). + append_submit_progress_lines(&mut lines, &app.deposit_form.submit_state); + let form = Paragraph::new(lines).block( Block::bordered().title(" Deposit | ↑↓ field Enter pick/submit Ctrl+M max Esc back "), ); @@ -745,7 +788,13 @@ fn amount_raw(app: &App) -> Option { fn render_withdraw_form(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { // Gate the form: if the user has no positions, show a guidance message. - if app.positions.is_empty() { + // (Skip the gate when we're showing a submit result — Done/Failed should + // remain visible even if positions haven't reloaded yet.) + let in_submit_result = matches!( + app.withdraw_form.submit_state, + SubmitState::Done { .. } | SubmitState::Failed { .. } + ); + if app.positions.is_empty() && !in_submit_result { let msg = Paragraph::new( "No Nest positions — deposit first.\n\nPress Tab to switch tabs, or 'r' to refresh positions.", ) @@ -757,6 +806,24 @@ fn render_withdraw_form(frame: &mut Frame, app: &mut App, area: ratatui::layout: let [form_area, status_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).areas(area); + match &app.withdraw_form.submit_state { + SubmitState::Done { receipts } => { + render_result_body(frame, form_area, " Withdraw — Done ", receipts, None); + let status = Paragraph::new(app.withdraw_form.status.as_str()) + .block(Block::bordered().title(" Status ")); + frame.render_widget(status, status_area); + return; + } + SubmitState::Failed { error } => { + render_failure_body(frame, form_area, " Withdraw — Failed ", error); + let status = Paragraph::new(app.withdraw_form.status.as_str()) + .block(Block::bordered().title(" Status ")); + frame.render_widget(status, status_area); + return; + } + _ => {} + } + let active = app.withdraw_form.active_field; let mut lines: Vec = vec![ field_row( @@ -845,6 +912,9 @@ fn render_withdraw_form(frame: &mut Frame, app: &mut App, area: ratatui::layout: // Submit row. lines.push(submit_row("Submit", active == WithdrawField::Submit)); + // Submit-pipeline progress / preview lines (v0.2.4). + append_submit_progress_lines(&mut lines, &app.withdraw_form.submit_state); + let form = Paragraph::new(lines).block( Block::bordered().title(" Withdraw | ↑↓ field Enter pick/submit Ctrl+M max Esc back "), ); @@ -947,3 +1017,153 @@ fn format_price(val: Option) -> String { None => "-".to_string(), } } + +// --------------------------------------------------------------------------- +// Submit-pipeline rendering (v0.2.4) +// --------------------------------------------------------------------------- + +/// Render a confirmed-receipts result body. +fn render_result_body( + frame: &mut Frame, + area: ratatui::layout::Rect, + title: &str, + receipts: &[TxResult], + pending: Option, +) { + let n = receipts.len().max(pending.unwrap_or(0)); + let mut lines: Vec = Vec::with_capacity(n + 2); + for (i, r) in receipts.iter().enumerate() { + lines.push(format_tx_result_line(i, n, r)); + } + if let Some(total) = pending + && receipts.len() < total + { + lines.push(Line::from(vec![Span::styled( + format!(" … [{}/{total}] pending", receipts.len() + 1), + Style::new().fg(Color::Yellow), + )])); + } + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Esc to return to form ", + Style::new() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )])); + let p = Paragraph::new(lines).block(Block::bordered().title(title.to_string())); + frame.render_widget(p, area); +} + +fn render_failure_body(frame: &mut Frame, area: ratatui::layout::Rect, title: &str, error: &str) { + let lines = vec![ + Line::from(vec![Span::styled( + format!(" ✗ {error}"), + Style::new().fg(Color::Red).add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![Span::styled( + " Esc to return to form ", + Style::new() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + )]), + ]; + let p = Paragraph::new(lines).block(Block::bordered().title(title.to_string())); + frame.render_widget(p, area); +} + +/// Public-ish helper so the test module can assert formatting deterministically. +pub(super) fn format_tx_result_line(i: usize, total: usize, r: &TxResult) -> Line<'static> { + let hash = short_addr(&r.tx_hash); + let mut spans = vec![ + Span::styled(" ✓ ", Style::new().fg(Color::Green)), + Span::raw(format!("[{}/{total}] ", i + 1)), + Span::styled(r.label.clone(), Style::new().add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::raw(hash), + Span::raw(format!(" block {}", r.block_number)), + ]; + if let Some(ref url) = r.explorer { + spans.push(Span::raw(" → ")); + spans.push(Span::styled(url.clone(), Style::new().fg(Color::Cyan))); + } + Line::from(spans) +} + +/// Inline progress / preview lines appended to the editable form body when +/// a submit is in flight (or just got built). +fn append_submit_progress_lines(lines: &mut Vec>, state: &SubmitState) { + match state { + SubmitState::Idle => {} + SubmitState::BuildingBundle => { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("⠋ Building transaction…", Style::new().fg(Color::Yellow)), + ])); + } + SubmitState::AwaitingConfirm(bundle) => { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Planned transactions:", + Style::new().add_modifier(Modifier::BOLD).fg(Color::Gray), + )])); + let n = bundle.transactions.len(); + for (i, tx) in bundle.transactions.iter().enumerate() { + let value_disp = if tx.value == "0" || tx.value.is_empty() { + "—".to_string() + } else { + tx.value.clone() + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::raw(format!("[{}/{n}] ", i + 1)), + Span::styled(tx.label.clone(), Style::new().add_modifier(Modifier::BOLD)), + Span::raw(" to: "), + Span::styled(short_addr(&tx.to), Style::new().fg(Color::DarkGray)), + Span::raw(" value: "), + Span::raw(value_disp), + ])); + } + } + SubmitState::Broadcasting { bundle, progress } => { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Broadcasting…", + Style::new().add_modifier(Modifier::BOLD).fg(Color::Cyan), + )])); + let n = bundle.transactions.len(); + for (i, p) in progress.iter().enumerate() { + let label = bundle + .transactions + .get(i) + .map(|t| t.label.as_str()) + .unwrap_or(""); + lines.push(match p { + TxProgress::Pending => Line::from(vec![ + Span::raw(" "), + Span::styled("⠋ ", Style::new().fg(Color::Yellow)), + Span::raw(format!("[{}/{n}] ", i + 1)), + Span::styled(label.to_string(), Style::new().fg(Color::Gray)), + Span::raw(" pending…"), + ]), + TxProgress::Confirmed(r) => format_tx_result_line(i, n, r), + TxProgress::Failed(e) => Line::from(vec![ + Span::raw(" "), + Span::styled( + "✗ ", + Style::new().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(format!("[{}/{n}] ", i + 1)), + Span::styled(label.to_string(), Style::new().add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled(e.clone(), Style::new().fg(Color::Red)), + ]), + }); + } + } + SubmitState::Done { .. } | SubmitState::Failed { .. } => { + // Body already swapped wholesale; nothing to append here. + } + } +}