From d970deb8f544aa6383ee3bd8b05bd39d96a7767f Mon Sep 17 00:00:00 2001 From: SatoshiSound Date: Mon, 13 Apr 2026 23:14:10 +0100 Subject: [PATCH 1/8] Work on Liquid Swap Refund Flow --- coincube-gui/src/app/breez/client.rs | 117 +++++++- coincube-gui/src/app/breez/mod.rs | 2 + coincube-gui/src/app/breez/swap_status.rs | 263 ++++++++++++++++++ coincube-gui/src/app/mod.rs | 99 ++++++- coincube-gui/src/app/state/liquid/receive.rs | 36 ++- .../src/app/state/liquid/transactions.rs | 224 +++++++++++++-- coincube-gui/src/app/view/liquid/receive.rs | 44 +-- .../src/app/view/liquid/transactions.rs | 202 +++++++++++--- coincube-gui/src/app/view/message.rs | 5 + coincube-gui/test_assets/global_settings.json | 5 +- docs/BREEZ_BTC_RECEIVE.md | 63 +++++ 11 files changed, 970 insertions(+), 90 deletions(-) create mode 100644 coincube-gui/src/app/breez/swap_status.rs create mode 100644 docs/BREEZ_BTC_RECEIVE.md diff --git a/coincube-gui/src/app/breez/client.rs b/coincube-gui/src/app/breez/client.rs index 8eb0fa282..7a0c45cd8 100644 --- a/coincube-gui/src/app/breez/client.rs +++ b/coincube-gui/src/app/breez/client.rs @@ -569,10 +569,49 @@ impl BreezClient { } pub async fn list_refundables(&self) -> Result, BreezError> { - self.get_sdk()? + let refundables = self + .get_sdk()? .list_refundables() .await - .map_err(|e| BreezError::Sdk(e.to_string())) + .map_err(|e| BreezError::Sdk(e.to_string()))?; + + if !refundables.is_empty() { + log::info!( + target: "breez_swap", + "list_refundables: {} refundable swap(s) discovered", + refundables.len() + ); + for r in &refundables { + log::info!( + target: "breez_swap", + " refundable: swap_address={} amount_sat={}", + truncate_addr(&r.swap_address), + r.amount_sat + ); + } + } + Ok(refundables) + } + + /// Fetch the SDK's recommended Bitcoin fee rates (sat/vB). + /// Used as a fallback fee source when the local mempool `FeeEstimator` + /// is unavailable or errors. + pub async fn recommended_fees(&self) -> Result { + let fees = self + .get_sdk()? + .recommended_fees() + .await + .map_err(|e| BreezError::Sdk(e.to_string()))?; + log::info!( + target: "breez_swap", + "recommended_fees: fastest={} half_hour={} hour={} economy={} minimum={}", + fees.fastest_fee, + fees.half_hour_fee, + fees.hour_fee, + fees.economy_fee, + fees.minimum_fee + ); + Ok(fees) } pub async fn fetch_payment_proposed_fees( @@ -611,10 +650,18 @@ impl BreezClient { } pub async fn fetch_onchain_limits(&self) -> Result { - self.get_sdk()? + let limits = self + .get_sdk()? .fetch_onchain_limits() .await - .map_err(|e| BreezError::Sdk(e.to_string())) + .map_err(|e| BreezError::Sdk(e.to_string()))?; + log::info!( + target: "breez_swap", + "fetch_onchain_limits: pair=btc-to-lbtc receive_min_sat={} receive_max_sat={}", + limits.receive.min_sat, + limits.receive.max_sat + ); + Ok(limits) } /// Manually trigger wallet synchronization with the blockchain @@ -636,10 +683,31 @@ impl BreezClient { &self, refund_request: breez::RefundRequest, ) -> Result { - self.get_sdk()? - .refund(&refund_request) - .await - .map_err(|e| BreezError::Sdk(e.to_string())) + log::info!( + target: "breez_swap", + "refund_onchain_tx: submitting refund swap_address={} refund_address={} fee_rate={}", + truncate_addr(&refund_request.swap_address), + truncate_addr(&refund_request.refund_address), + refund_request.fee_rate_sat_per_vbyte + ); + match self.get_sdk()?.refund(&refund_request).await { + Ok(resp) => { + log::info!( + target: "breez_swap", + "refund_onchain_tx: success refund_tx_id={}", + truncate_addr(&resp.refund_tx_id) + ); + Ok(resp) + } + Err(e) => { + log::error!( + target: "breez_swap", + "refund_onchain_tx: failed: {}", + e + ); + Err(BreezError::Sdk(e.to_string())) + } + } } pub fn liquid_signer(&self) -> Option>> { @@ -696,6 +764,18 @@ fn make_breez_stream(state: &BreezSubscriptionState) -> impl Stream String { + if s.len() <= 14 { + return s.to_string(); + } + let head = &s[..6]; + let tail = &s[s.len() - 6..]; + format!("{head}…{tail}") +} + /// Converts `amount` base-units to an `f64` display value by dividing by `10^precision`. /// /// Returns `Err(BreezError::Sdk)` if: @@ -728,3 +808,24 @@ impl breez::EventListener for BreezEventListener { let _ = self.sender.send(e); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_addr_short_unchanged() { + assert_eq!(truncate_addr("bc1qabc"), "bc1qabc"); + } + + #[test] + fn truncate_addr_long_middle_elided() { + let long = "bc1p7gznc2zpn7aq3vqd695eml450d2ls33vw65tvwd77x936jquadnsp7ff6v"; + assert_eq!(truncate_addr(long), "bc1p7g…p7ff6v"); + } + + #[test] + fn truncate_addr_exactly_14_chars_unchanged() { + assert_eq!(truncate_addr("12345678901234"), "12345678901234"); + } +} diff --git a/coincube-gui/src/app/breez/mod.rs b/coincube-gui/src/app/breez/mod.rs index f9f145735..6dc05ad9c 100644 --- a/coincube-gui/src/app/breez/mod.rs +++ b/coincube-gui/src/app/breez/mod.rs @@ -1,9 +1,11 @@ pub mod assets; mod client; mod config; +pub mod swap_status; pub use client::BreezClient; pub use config::BreezConfig; +pub use swap_status::{classify_payment, classify_refundable, BtcSwapReceiveStatus}; // Re-export Breez SDK response types pub use breez_sdk_liquid::prelude::{GetInfoResponse, ReceivePaymentResponse, SendPaymentResponse}; diff --git a/coincube-gui/src/app/breez/swap_status.rs b/coincube-gui/src/app/breez/swap_status.rs new file mode 100644 index 000000000..6dff1efa4 --- /dev/null +++ b/coincube-gui/src/app/breez/swap_status.rs @@ -0,0 +1,263 @@ +//! BTC → L-BTC swap receive status model. +//! +//! The Breez Liquid SDK exposes low-level `PaymentState` and `SdkEvent` values +//! that the GUI must translate into user-friendly lifecycle stages. Classifying +//! those states in one place lets the receive and transactions views ask a +//! single question — "what should I show for this swap?" — rather than +//! re-deriving the answer from raw SDK fields in every screen. +//! +//! This module is scoped to the **Liquid wallet's BTC onchain receive** path +//! (which is a Boltz-style swap from native BTC to L-BTC). The Vault wallet is +//! natively Bitcoin and does not use this model. + +use breez_sdk_liquid::prelude::{ + Payment, PaymentDetails, PaymentState, PaymentType, RefundableSwap, +}; + +/// Lifecycle stage of a single BTC→L-BTC swap receive, as surfaced in the +/// Liquid wallet's receive flow. +/// +/// This is a UI-facing projection of `PaymentState` combined with the presence +/// of the swap in `list_refundables()`. The mapping lives in +/// [`classify_payment`] / [`classify_refundable`] — extend those functions when +/// the SDK adds new states or events. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BtcSwapReceiveStatus { + /// Address has been generated, no BTC deposit has been seen on chain yet. + AwaitingDeposit, + /// The BTC lockup tx has been seen in the mempool, waiting on on-chain + /// confirmations before the swap can progress. + PendingConfirmation, + /// BTC is confirmed on chain; the Boltz swap to L-BTC is in flight and we + /// are waiting for the claim tx. + PendingSwapCompletion, + /// The swapper proposed new fees that must be accepted before the payment + /// can proceed. User action is required. + WaitingFeeAcceptance, + /// The swap failed or timed out and the funds can be recovered via an + /// in-app BTC refund transaction. Appears in `list_refundables()`. + Refundable, + /// The user has submitted a refund; the refund tx is being broadcast but + /// not yet confirmed. + Refunding, + /// The refund tx has been confirmed. + Refunded, + /// The swap settled and L-BTC has been credited to the Liquid wallet. + Completed, + /// Terminal failure with no refundable funds (e.g. timed out before the + /// lockup tx was broadcast). + Failed, +} + +impl BtcSwapReceiveStatus { + /// Short, user-facing label for this status. + pub fn label(&self) -> &'static str { + match self { + Self::AwaitingDeposit => "Awaiting deposit", + Self::PendingConfirmation => "Pending confirmation", + Self::PendingSwapCompletion => "Swap in progress", + Self::WaitingFeeAcceptance => "Fee review needed", + Self::Refundable => "Refundable", + Self::Refunding => "Refund broadcasting", + Self::Refunded => "Refunded", + Self::Completed => "Received", + Self::Failed => "Failed", + } + } +} + +/// Classify a `Payment` that originated from a BTC onchain receive swap. +/// +/// Mapping (SDK → internal): +/// - `Created` → `AwaitingDeposit` +/// - `Pending` (no lockup tx seen) → `PendingConfirmation` +/// - `Pending` (lockup tx seen) → `PendingSwapCompletion` +/// - `WaitingFeeAcceptance` → `WaitingFeeAcceptance` +/// - `Refundable` → `Refundable` +/// - `RefundPending` → `Refunding` +/// - `Failed` → `Refundable` if the swap still appears in `list_refundables`, +/// otherwise `Failed`. Since that fact lives outside a single `Payment`, +/// callers pass `refundable_swap_addresses` to resolve it. +/// - `TimedOut` → `Failed` +/// - `Complete` (receive) → `Completed` +/// - `Complete` (send on same swap — the refund leg) → `Refunded` +/// +/// `refundable_swap_addresses` is the set of `swap_address` strings currently +/// returned by `BreezClient::list_refundables()`. Pass an empty slice when +/// that context is unavailable (the classifier will fall back to `Failed`). +pub fn classify_payment(p: &Payment, refundable_swap_addresses: &[String]) -> BtcSwapReceiveStatus { + let bitcoin_addr = match &p.details { + PaymentDetails::Bitcoin { + bitcoin_address, + lockup_tx_id, + .. + } => Some((bitcoin_address.clone(), lockup_tx_id.is_some())), + _ => None, + }; + + match p.status { + PaymentState::Created => BtcSwapReceiveStatus::AwaitingDeposit, + PaymentState::Pending => match bitcoin_addr { + Some((_, lockup_seen)) if lockup_seen => BtcSwapReceiveStatus::PendingSwapCompletion, + _ => BtcSwapReceiveStatus::PendingConfirmation, + }, + PaymentState::WaitingFeeAcceptance => BtcSwapReceiveStatus::WaitingFeeAcceptance, + PaymentState::Refundable => BtcSwapReceiveStatus::Refundable, + PaymentState::RefundPending => BtcSwapReceiveStatus::Refunding, + PaymentState::Failed => { + if let Some((addr, _)) = bitcoin_addr { + if refundable_swap_addresses.contains(&addr) { + return BtcSwapReceiveStatus::Refundable; + } + } + BtcSwapReceiveStatus::Failed + } + PaymentState::TimedOut => BtcSwapReceiveStatus::Failed, + PaymentState::Complete => match p.payment_type { + PaymentType::Receive => BtcSwapReceiveStatus::Completed, + // A Complete send on a BTC swap details means the refund leg has + // confirmed on chain. + PaymentType::Send => BtcSwapReceiveStatus::Refunded, + }, + } +} + +/// A `RefundableSwap` coming back from `list_refundables()` is always in the +/// refundable state — this is the obvious classifier, kept as a function so +/// callers can treat refundables and payments uniformly. +pub fn classify_refundable(_swap: &RefundableSwap) -> BtcSwapReceiveStatus { + BtcSwapReceiveStatus::Refundable +} + +#[cfg(test)] +mod tests { + use super::*; + use breez_sdk_liquid::prelude::{Payment, PaymentDetails, PaymentState, PaymentType}; + + fn btc_payment(status: PaymentState, ptype: PaymentType, lockup: Option) -> Payment { + Payment { + destination: Some("bc1qtest".into()), + tx_id: None, + unblinding_data: None, + timestamp: 0, + amount_sat: 50_000, + fees_sat: 0, + swapper_fees_sat: Some(0), + payment_type: ptype, + status, + details: PaymentDetails::Bitcoin { + swap_id: "swap-xyz".into(), + bitcoin_address: "bc1qtest".into(), + description: String::new(), + auto_accepted_fees: false, + liquid_expiration_blockheight: 0, + bitcoin_expiration_blockheight: 0, + lockup_tx_id: lockup, + claim_tx_id: None, + refund_tx_id: None, + refund_tx_amount_sat: None, + }, + } + } + + #[test] + fn created_maps_to_awaiting_deposit() { + let p = btc_payment(PaymentState::Created, PaymentType::Receive, None); + assert_eq!( + classify_payment(&p, &[]), + BtcSwapReceiveStatus::AwaitingDeposit + ); + } + + #[test] + fn pending_without_lockup_is_pending_confirmation() { + let p = btc_payment(PaymentState::Pending, PaymentType::Receive, None); + assert_eq!( + classify_payment(&p, &[]), + BtcSwapReceiveStatus::PendingConfirmation + ); + } + + #[test] + fn pending_with_lockup_is_pending_swap_completion() { + let p = btc_payment( + PaymentState::Pending, + PaymentType::Receive, + Some("txid".into()), + ); + assert_eq!( + classify_payment(&p, &[]), + BtcSwapReceiveStatus::PendingSwapCompletion + ); + } + + #[test] + fn refundable_state_is_refundable() { + let p = btc_payment(PaymentState::Refundable, PaymentType::Receive, None); + assert_eq!(classify_payment(&p, &[]), BtcSwapReceiveStatus::Refundable); + } + + #[test] + fn refund_pending_is_refunding() { + let p = btc_payment(PaymentState::RefundPending, PaymentType::Send, None); + assert_eq!(classify_payment(&p, &[]), BtcSwapReceiveStatus::Refunding); + } + + #[test] + fn failed_with_refundable_address_upgrades_to_refundable() { + let p = btc_payment(PaymentState::Failed, PaymentType::Receive, None); + let refundables = vec!["bc1qtest".to_string()]; + assert_eq!( + classify_payment(&p, &refundables), + BtcSwapReceiveStatus::Refundable + ); + } + + #[test] + fn failed_without_refundable_address_is_failed() { + let p = btc_payment(PaymentState::Failed, PaymentType::Receive, None); + assert_eq!(classify_payment(&p, &[]), BtcSwapReceiveStatus::Failed); + } + + #[test] + fn timed_out_is_failed() { + let p = btc_payment(PaymentState::TimedOut, PaymentType::Receive, None); + assert_eq!(classify_payment(&p, &[]), BtcSwapReceiveStatus::Failed); + } + + #[test] + fn complete_receive_is_completed() { + let p = btc_payment(PaymentState::Complete, PaymentType::Receive, None); + assert_eq!(classify_payment(&p, &[]), BtcSwapReceiveStatus::Completed); + } + + #[test] + fn complete_send_on_btc_swap_is_refunded() { + let p = btc_payment(PaymentState::Complete, PaymentType::Send, None); + assert_eq!(classify_payment(&p, &[]), BtcSwapReceiveStatus::Refunded); + } + + #[test] + fn waiting_fee_acceptance_maps() { + let p = btc_payment( + PaymentState::WaitingFeeAcceptance, + PaymentType::Receive, + None, + ); + assert_eq!( + classify_payment(&p, &[]), + BtcSwapReceiveStatus::WaitingFeeAcceptance + ); + } + + #[test] + fn classify_refundable_always_refundable() { + let r = RefundableSwap { + swap_address: "bc1q".into(), + timestamp: 0, + amount_sat: 10_000, + last_refund_tx_id: None, + }; + assert_eq!(classify_refundable(&r), BtcSwapReceiveStatus::Refundable); + } +} diff --git a/coincube-gui/src/app/mod.rs b/coincube-gui/src/app/mod.rs index d93ab21af..416cb3f62 100644 --- a/coincube-gui/src/app/mod.rs +++ b/coincube-gui/src/app/mod.rs @@ -580,6 +580,12 @@ pub struct App { /// the same swap; bounded FIFO so concurrent incoming swaps don't evict /// each other and re-toast. toasted_incoming_waiting_tx_ids: VecDeque, + /// Debounces event-driven `list_refundables()` polls. Breez fires `Synced` + /// and payment events frequently; without a debounce window the GUI would + /// hammer the SDK several times a minute. 30s is short enough that a + /// freshly-refundable swap surfaces without user action but long enough to + /// avoid noisy churn. + last_refundables_fetch: Option, } /// Returns true when a `DaemonError` indicates the daemon process is no longer @@ -715,6 +721,7 @@ impl App { "transaction-received", ), toasted_incoming_waiting_tx_ids: VecDeque::with_capacity(16), + last_refundables_fetch: None, }, cmd, ) @@ -789,6 +796,7 @@ impl App { "transaction-received", ), toasted_incoming_waiting_tx_ids: VecDeque::with_capacity(16), + last_refundables_fetch: None, }, cmd, ) @@ -1249,6 +1257,31 @@ impl App { Task::batch(tasks) } + /// Kick off a background `list_refundables()` poll, debounced so that + /// SDK events (which can fire several times a second during sync) don't + /// hammer the SDK. Result is routed back to `Message::RefundablesLoaded`, + /// which `LiquidTransactions::update()` already handles. + /// + /// The Transactions panel itself fetches refundables on every reload() + /// too — this debounced helper covers the case where the user is sitting + /// on a non-Transactions screen while a swap becomes refundable, so they + /// still see it the next time they navigate or glance at the app. + fn refresh_refundables_task(&mut self) -> Task { + const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(30); + let now = std::time::Instant::now(); + if let Some(prev) = self.last_refundables_fetch { + if now.duration_since(prev) < DEBOUNCE { + return Task::none(); + } + } + self.last_refundables_fetch = Some(now); + let client = self.breez_client.clone(); + Task::perform( + async move { client.list_refundables().await }, + Message::RefundablesLoaded, + ) + } + pub fn update(&mut self, message: Message) -> Task { match message { Message::View(view::Message::DismissToast(id)) => { @@ -1889,6 +1922,50 @@ impl App { if let Some(msg) = self.panels.active_liquid_refresh(true) { tasks.push(Task::done(msg)); } + // A failed BTC→L-BTC swap may have become refundable — let the + // transactions panel know so the user sees the Refund CTA. + tasks.push(self.refresh_refundables_task()); + return Task::batch(tasks); + } + SdkEvent::PaymentRefundable { details } => { + log::info!( + target: "breez_swap", + "SdkEvent::PaymentRefundable tx_id={:?}", + details.tx_id + ); + let mut tasks = Vec::new(); + if let Some(msg) = self.panels.active_liquid_refresh(true) { + tasks.push(Task::done(msg)); + } + tasks.push(self.refresh_refundables_task()); + return Task::batch(tasks); + } + SdkEvent::PaymentRefundPending { details } => { + log::info!( + target: "breez_swap", + "SdkEvent::PaymentRefundPending tx_id={:?}", + details.tx_id + ); + let mut tasks = Vec::new(); + if let Some(msg) = self.panels.active_liquid_refresh(true) { + tasks.push(Task::done(msg)); + } + tasks.push(self.refresh_refundables_task()); + return Task::batch(tasks); + } + SdkEvent::PaymentRefunded { details } => { + log::info!( + target: "breez_swap", + "SdkEvent::PaymentRefunded tx_id={:?}", + details.tx_id + ); + let mut tasks = vec![Task::done(Message::View(view::Message::Home( + view::HomeMessage::RefreshLiquidBalance, + )))]; + if let Some(msg) = self.panels.active_liquid_refresh(true) { + tasks.push(Task::done(msg)); + } + tasks.push(self.refresh_refundables_task()); return Task::batch(tasks); } SdkEvent::PaymentWaitingConfirmation { details } => { @@ -1942,8 +2019,16 @@ impl App { // SDK completed an internal sync — refresh only the // active liquid panel to avoid redundant info() calls. // Inactive panels refresh when navigated to via reload(). + let mut tasks = Vec::new(); if let Some(msg) = self.panels.active_liquid_refresh(false) { - return Task::done(msg); + tasks.push(Task::done(msg)); + } + // Debounced refundables poll — picks up older expired + // swaps that didn't emit an explicit refundable event + // while the app was offline. + tasks.push(self.refresh_refundables_task()); + if !tasks.is_empty() { + return Task::batch(tasks); } } _ => { @@ -1960,6 +2045,18 @@ impl App { return p2p.update(self.daemon.clone(), &self.cache, msg); } } + // Route refundables updates directly to LiquidTransactions so that + // event-driven `list_refundables()` polls (fired from `BreezEvent` + // handlers above) land on the correct panel even when the user is + // sitting on a different screen. Otherwise the result would be + // dropped into whatever panel happens to be current. + msg @ Message::RefundablesLoaded(_) => { + return self.panels.liquid_transactions.update( + self.daemon.clone(), + &self.cache, + msg, + ); + } msg => { if let (Some(daemon), Some(panel)) = (self.daemon.clone(), self.panels.current_mut()) diff --git a/coincube-gui/src/app/state/liquid/receive.rs b/coincube-gui/src/app/state/liquid/receive.rs index 19b8086c7..8a67f6663 100644 --- a/coincube-gui/src/app/state/liquid/receive.rs +++ b/coincube-gui/src/app/state/liquid/receive.rs @@ -1086,12 +1086,22 @@ impl LiquidReceive { ) } + /// Fetch lightning + onchain receive limits from the SDK. + /// + /// Onchain limits are fetched eagerly (regardless of which tab is + /// currently open) so that when the user switches to the Bitcoin tab they + /// immediately see the min/max and the swap-receive warning copy. Without + /// this, the limits are fetched lazily on tab open and the warning block + /// would flash empty for a moment, or worse, the user could generate an + /// address before seeing the constraints. + /// + /// Lightning limits are cheap and fetched alongside. fn fetch_limits(&mut self) -> Task { - if self.lightning_receive_limits.is_none() - && matches!(self.receive_method, ReceiveMethod::Lightning) - { + let mut tasks = Vec::new(); + + if self.lightning_receive_limits.is_none() { let breez_client = self.breez_client.clone(); - Task::perform( + tasks.push(Task::perform( async move { breez_client.fetch_lightning_limits().await }, |response| match response { Ok(limits) => Message::View(view::Message::LiquidReceive( @@ -1104,12 +1114,12 @@ impl LiquidReceive { LiquidReceiveMessage::Error(error.to_string()), )), }, - ) - } else if self.onchain_receive_limits.is_none() - && matches!(self.receive_method, ReceiveMethod::OnChain) - { + )); + } + + if self.onchain_receive_limits.is_none() { let breez_client = self.breez_client.clone(); - Task::perform( + tasks.push(Task::perform( async move { breez_client.fetch_onchain_limits().await }, |response| match response { Ok(limits) => Message::View(view::Message::LiquidReceive( @@ -1122,9 +1132,13 @@ impl LiquidReceive { LiquidReceiveMessage::Error(error.to_string()), )), }, - ) - } else { + )); + } + + if tasks.is_empty() { Task::none() + } else { + Task::batch(tasks) } } } diff --git a/coincube-gui/src/app/state/liquid/transactions.rs b/coincube-gui/src/app/state/liquid/transactions.rs index c863f313c..af5c22432 100644 --- a/coincube-gui/src/app/state/liquid/transactions.rs +++ b/coincube-gui/src/app/state/liquid/transactions.rs @@ -1,5 +1,7 @@ +use std::collections::HashMap; use std::convert::TryInto; use std::sync::Arc; +use std::time::Instant; use breez_sdk_liquid::model::{PaymentDetails, RefundRequest}; use breez_sdk_liquid::prelude::{Payment, RefundableSwap}; @@ -17,6 +19,17 @@ use crate::daemon::Daemon; use crate::export::{ImportExportMessage, ImportExportState}; use crate::services::feeestimation::fee_estimation::FeeEstimator; +/// A refund that the user has submitted but for which we have not yet seen the +/// SDK drop the swap from `list_refundables()`. While an entry exists, the +/// Transactions view keeps rendering the refundable so the user gets a visible +/// "Refund broadcasting…" / "Refund broadcast · txid …" confirmation instead +/// of the card vanishing silently on success. +#[derive(Debug, Clone)] +pub struct InFlightRefund { + pub refund_txid: Option, + pub submitted_at: Instant, +} + #[derive(Debug)] enum LiquidTransactionsModal { None, @@ -37,6 +50,12 @@ pub struct LiquidTransactions { fee_estimator: FeeEstimator, refunding: bool, asset_filter: AssetFilter, + /// While a fee-priority button is awaiting its async rate fetch, this + /// holds which one was pressed so the view can show a "…" spinner on it. + pending_fee_priority: Option, + /// Refunds submitted by the user that have not yet been dropped from the + /// SDK's refundables list. Keyed by swap_address. See `InFlightRefund`. + in_flight_refunds: HashMap, empty_state_quote: Quote, empty_state_image_handle: image::Handle, } @@ -66,11 +85,30 @@ impl LiquidTransactions { fee_estimator: FeeEstimator::new(), refunding: false, asset_filter: AssetFilter::All, + pending_fee_priority: None, + in_flight_refunds: HashMap::new(), empty_state_quote, empty_state_image_handle, } } + pub fn in_flight_refunds(&self) -> &HashMap { + &self.in_flight_refunds + } + + pub fn pending_fee_priority(&self) -> Option { + self.pending_fee_priority + } + + #[cfg(test)] + pub fn test_reconcile_in_flight(&mut self, refundables: Vec) { + let returned: std::collections::HashSet<&String> = + refundables.iter().map(|r| &r.swap_address).collect(); + self.in_flight_refunds + .retain(|addr, _| returned.contains(addr)); + self.refundables = refundables; + } + pub fn asset_filter(&self) -> AssetFilter { self.asset_filter } @@ -142,6 +180,8 @@ impl State for LiquidTransactions { &self.refund_address, &self.refund_feerate, self.refunding, + self.pending_fee_priority, + self.in_flight_refunds.get(&refundable.swap_address), ), ) } else { @@ -151,6 +191,7 @@ impl State for LiquidTransactions { view::liquid::liquid_transactions_view( &self.payments, &self.refundables, + &self.in_flight_refunds, &self.balance, fiat_converter, self.loading, @@ -194,7 +235,7 @@ impl State for LiquidTransactions { fn update( &mut self, - _daemon: Option>, + daemon: Option>, _cache: &Cache, message: Message, ) -> Task { @@ -233,6 +274,16 @@ impl State for LiquidTransactions { Task::done(Message::View(view::Message::ShowError(e.to_string()))) } Message::RefundablesLoaded(Ok(refundables)) => { + // Reconcile in-flight refunds with the freshly-fetched list. + // A swap leaves `list_refundables` once the SDK observes our + // refund tx, so anything tracked locally that is no longer in + // the SDK's list is complete and can be dropped from the + // "broadcasting" banner. This prevents a stale "Refund + // broadcasting…" from sticking around forever. + let returned: std::collections::HashSet<&String> = + refundables.iter().map(|r| &r.swap_address).collect(); + self.in_flight_refunds + .retain(|addr, _| returned.contains(addr)); self.refundables = refundables; Task::none() } @@ -368,27 +419,46 @@ impl State for LiquidTransactions { self.refund_feerate.value = feerate; self.refund_feerate.valid = true; self.refund_feerate.warning = None; + // Any incoming edit — whether from the user or from a + // priority-button async resolution — clears the spinner so + // the pressed button stops showing "…". + self.pending_fee_priority = None; Task::none() } Message::View(view::Message::RefundFeeratePrioritySelected(priority)) => { + // Record which button was pressed so the view can render a + // "…" spinner while the async fee fetch is in flight. + self.pending_fee_priority = Some(priority); let fee_estimator = self.fee_estimator.clone(); + let breez_client = self.breez_client.clone(); Task::perform( async move { - let rate: Option = match priority { + // Primary source: local mempool FeeEstimator. Falls + // through to the SDK's `recommended_fees()` if the + // local estimator errors so we can still populate a + // sensible rate when the user's network is flaky. + let local: Option = match priority { FeeratePriority::Low => { - let result = fee_estimator.get_low_priority_rate().await; - result.ok() + fee_estimator.get_low_priority_rate().await.ok() } FeeratePriority::Medium => { - let result = fee_estimator.get_mid_priority_rate().await; - result.ok() + fee_estimator.get_mid_priority_rate().await.ok() } FeeratePriority::High => { - let result = fee_estimator.get_high_priority_rate().await; - result.ok() + fee_estimator.get_high_priority_rate().await.ok() } }; - rate + if let Some(rate) = local { + return Some(rate); + } + match breez_client.recommended_fees().await { + Ok(fees) => Some(match priority { + FeeratePriority::Low => fees.economy_fee as usize, + FeeratePriority::Medium => fees.half_hour_fee as usize, + FeeratePriority::High => fees.fastest_fee as usize, + }), + Err(_) => None, + } }, move |rate: Option| { if let Some(rate) = rate { @@ -401,24 +471,62 @@ impl State for LiquidTransactions { }, ) } + Message::View(view::Message::GenerateVaultRefundAddress) => { + // Reuse the Vault wallet's existing fresh-address derivation + // (`daemon.get_new_address()`). This intentionally does NOT + // duplicate descriptor logic — the Vault remains the single + // source of truth for native BTC addresses in this app. + let Some(daemon) = daemon else { + return Task::done(Message::View(view::Message::ShowError( + "Vault is unavailable — cannot generate a refund address.".to_string(), + ))); + }; + Task::perform( + async move { + let res: Result = daemon + .get_new_address() + .await + .map(|res| res.address.to_string()) + .map_err(|e| e.to_string()); + res + }, + |result| match result { + Ok(addr) => Message::View(view::Message::RefundAddressEdited(addr)), + Err(e) => Message::View(view::Message::ShowError(format!( + "Could not generate Vault refund address: {}", + e + ))), + }, + ) + } Message::View(view::Message::SubmitRefund) => { if let Some(refundable) = &self.selected_refundable { self.refunding = true; - let breez_client = self.breez_client.clone(); let swap_address = refundable.swap_address.clone(); + // Optimistically record the in-flight refund so the view + // keeps the card visible with a "broadcasting" banner + // even if the SDK drops the swap from `list_refundables` + // before `RefundCompleted` fires. + self.in_flight_refunds.insert( + swap_address.clone(), + InFlightRefund { + refund_txid: None, + submitted_at: Instant::now(), + }, + ); + let breez_client = self.breez_client.clone(); let refund_address = self.refund_address.value.clone(); let fee_rate = self.refund_feerate.value.parse::().unwrap_or(1); Task::perform( async move { - let result = breez_client + breez_client .refund_onchain_tx(RefundRequest { - swap_address: swap_address.clone(), - refund_address: refund_address.clone(), + swap_address, + refund_address, fee_rate_sat_per_vbyte: fee_rate, }) - .await; - result + .await }, Message::RefundCompleted, ) @@ -427,15 +535,37 @@ impl State for LiquidTransactions { Task::none() } } - Message::RefundCompleted(Ok(_response)) => { + Message::RefundCompleted(Ok(response)) => { self.refunding = false; + let txid = response.refund_tx_id.clone(); + // Populate the refund_txid on the most recent in-flight entry + // that doesn't yet have one. We don't have the swap_address + // on the response, so match on the missing txid field. + if let Some(entry) = self + .in_flight_refunds + .values_mut() + .find(|r| r.refund_txid.is_none()) + { + entry.refund_txid = Some(txid.clone()); + } self.selected_refundable = None; self.refund_address = form::Value::default(); self.refund_feerate = form::Value::default(); - Task::done(Message::View(view::Message::Close)) + Task::batch(vec![ + Task::done(Message::View(view::Message::ShowToast( + log::Level::Info, + format!("Refund broadcast · {}", txid.get(..10).unwrap_or(&txid)), + ))), + Task::done(Message::View(view::Message::Close)), + ]) } Message::RefundCompleted(Err(e)) => { self.refunding = false; + // Drop any in-flight entry that doesn't have a txid — + // submission never reached broadcast, so leaving a stale + // "broadcasting" banner up would lie to the user. + self.in_flight_refunds + .retain(|_, r| r.refund_txid.is_some()); Task::done(Message::View(view::Message::ShowError(format!( "Refund failed: {}", e @@ -468,3 +598,63 @@ impl State for LiquidTransactions { ]) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::breez::BreezClient; + use breez_sdk_liquid::bitcoin::Network; + + fn sample_refundable(addr: &str) -> RefundableSwap { + RefundableSwap { + swap_address: addr.to_string(), + timestamp: 0, + amount_sat: 24_869, + last_refund_tx_id: None, + } + } + + fn new_state() -> LiquidTransactions { + LiquidTransactions::new(Arc::new(BreezClient::disconnected(Network::Bitcoin))) + } + + #[test] + fn in_flight_dropped_when_sdk_no_longer_returns_it() { + let mut state = new_state(); + state.in_flight_refunds.insert( + "bc1q_gone".to_string(), + InFlightRefund { + refund_txid: Some("deadbeef".to_string()), + submitted_at: Instant::now(), + }, + ); + state.in_flight_refunds.insert( + "bc1q_still".to_string(), + InFlightRefund { + refund_txid: None, + submitted_at: Instant::now(), + }, + ); + + // After reconcile: only swaps still returned by the SDK survive. + state.test_reconcile_in_flight(vec![sample_refundable("bc1q_still")]); + + assert!(state.in_flight_refunds.contains_key("bc1q_still")); + assert!(!state.in_flight_refunds.contains_key("bc1q_gone")); + assert_eq!(state.refundables.len(), 1); + } + + #[test] + fn in_flight_preserved_while_sdk_still_returns_swap() { + let mut state = new_state(); + state.in_flight_refunds.insert( + "bc1q_active".to_string(), + InFlightRefund { + refund_txid: None, + submitted_at: Instant::now(), + }, + ); + state.test_reconcile_in_flight(vec![sample_refundable("bc1q_active")]); + assert!(state.in_flight_refunds.contains_key("bc1q_active")); + } +} diff --git a/coincube-gui/src/app/view/liquid/receive.rs b/coincube-gui/src/app/view/liquid/receive.rs index f18cc2fa3..f1b5818be 100644 --- a/coincube-gui/src/app/view/liquid/receive.rs +++ b/coincube-gui/src/app/view/liquid/receive.rs @@ -77,7 +77,19 @@ pub fn liquid_receive_view<'a>( _ => { // Liquid / OnChain: only show generate button if no address is displayed if address.is_none() && !loading { - content = content.push(generate_button()); + // For the BTC onchain swap-receive tab, wait until the dynamic + // min/max swap limits have been fetched from the SDK before + // letting the user generate an address — the warning box below + // depends on them and we don't want to show a receive address + // without the user first seeing the constraints. + if *receive_method == ReceiveMethod::OnChain && onchain_limits.is_none() { + content = content.push(onchain_warning_box(onchain_limits, bitcoin_unit)); + content = content.push(crate::loading::loading_indicator(Some( + "Fetching swap limits", + ))); + } else { + content = content.push(generate_button()); + } } } } @@ -414,7 +426,7 @@ fn method_toggle(current_method: &ReceiveMethod) -> Element( Row::new() .spacing(8) .push(icon::warning_icon().size(16).color(color::ORANGE)) - .push( - text("Important") - .size(14) - .bold() - .style(|_theme: &theme::Theme| iced::widget::text::Style { - color: Some(color::ORANGE), - }), - ), + .push(text("Bitcoin onchain → L-BTC swap").size(14).bold().style( + |_theme: &theme::Theme| iced::widget::text::Style { + color: Some(color::ORANGE), + }, + )), ); if let Some((min_sat, max_sat)) = onchain_limits { @@ -803,7 +812,7 @@ fn onchain_warning_box<'a>( let max_btc = Amount::from_sat(max_sat); warning_content = warning_content.push( text(format!( - "- Receive amount must be between {} and {}", + "- Send between {} and {} — outside this range the swap cannot settle.", min_btc.to_formatted_string_with_unit(bitcoin_unit), max_btc.to_formatted_string_with_unit(bitcoin_unit), )) @@ -812,7 +821,7 @@ fn onchain_warning_box<'a>( ); } else { warning_content = warning_content.push( - text("- Receive amount must be within the specified limits") + text("- Fetching current swap limits…") .size(14) .style(theme::text::secondary), ); @@ -820,14 +829,17 @@ fn onchain_warning_box<'a>( warning_content = warning_content .push( - text("- Use this address for ONE transaction only") + text("- Use this address for ONE deposit only.") .size(14) .style(theme::text::secondary), ) .push( - text("- For multiple transactions, generate new addresses") - .size(14) - .style(theme::text::secondary), + text( + "- If your deposit is outside the range or the swap fails, \ + funds are recoverable via Refund in the Transactions tab.", + ) + .size(14) + .style(theme::text::secondary), ); Container::new(warning_content) diff --git a/coincube-gui/src/app/view/liquid/transactions.rs b/coincube-gui/src/app/view/liquid/transactions.rs index fe5dd0b91..050a632e4 100644 --- a/coincube-gui/src/app/view/liquid/transactions.rs +++ b/coincube-gui/src/app/view/liquid/transactions.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use breez_sdk_liquid::model::{PaymentDetails, PaymentState}; use breez_sdk_liquid::prelude::{Payment, PaymentType, RefundableSwap}; use coincube_core::miniscript::bitcoin::Amount; @@ -15,7 +17,7 @@ use coincube_ui::{ widget::*, }; use iced::{ - widget::{Column, Container, Row, Space}, + widget::{scrollable, Column, Container, Row, Space}, Alignment, Length, }; @@ -23,12 +25,21 @@ use coincube_ui::image::asset_network_logo; use crate::app::breez::assets::{format_usdt_display, USDT_PRECISION}; use crate::app::menu::Menu; -use crate::app::state::liquid::transactions::AssetFilter; +use crate::app::state::liquid::transactions::{AssetFilter, InFlightRefund}; use crate::app::view::message::{FeeratePriority, Message}; use crate::app::view::FiatAmountConverter; use crate::export::ImportExportMessage; use crate::utils::{format_time_ago, format_timestamp}; +/// Truncate a long on-chain address / txid like `bc1p7g…7ff6v` so it fits +/// inside a card without overflowing. Used for display only. +fn truncate_middle(s: &str, prefix_len: usize, suffix_len: usize) -> String { + if s.len() <= prefix_len + suffix_len + 3 { + return s.to_string(); + } + format!("{}…{}", &s[..prefix_len], &s[s.len() - suffix_len..]) +} + /// Returns `Some(formatted_usdt_string)` when the payment is a USDt asset payment. fn usdt_amount_str(payment: &Payment, usdt_id: &str) -> Option { if let PaymentDetails::Liquid { @@ -55,6 +66,7 @@ fn usdt_amount_str(payment: &Payment, usdt_id: &str) -> Option { pub fn liquid_transactions_view<'a>( payments: &'a [Payment], refundables: &'a [RefundableSwap], + in_flight_refunds: &'a HashMap, _balance: &'a Amount, fiat_converter: Option, _loading: bool, @@ -163,7 +175,12 @@ pub fn liquid_transactions_view<'a>( ); } - if !refundables.is_empty() { + // Refundables are always BTC → L-BTC swap refunds, so they only belong + // under the "All" and "L-BTC" filters. Previously they leaked into the + // USDt tab — fixed here. + let show_refundables = + !refundables.is_empty() && matches!(asset_filter, AssetFilter::All | AssetFilter::LbtcOnly); + if show_refundables { content = content.push( Column::new() .spacing(10) @@ -175,6 +192,7 @@ pub fn liquid_transactions_view<'a>( col.push(refundable_row( i, refundable, + in_flight_refunds.get(&refundable.swap_address), fiat_converter, bitcoin_unit, show_direction_badges, @@ -277,6 +295,7 @@ fn transaction_row<'a>( fn refundable_row<'a>( i: usize, refundable: &'a RefundableSwap, + in_flight: Option<&'a InFlightRefund>, fiat_converter: Option, bitcoin_unit: coincube_ui::component::amount::BitcoinDisplayUnit, show_direction_badges: bool, @@ -286,8 +305,23 @@ fn refundable_row<'a>( let direction = TransactionDirection::Incoming; + // If we have an in-flight refund for this swap, reflect that in the row + // label so the user can tell at a glance that their submission is being + // broadcast. Previously the card either disappeared or looked identical + // to "not yet refunded". + let label = match in_flight { + Some(InFlightRefund { + refund_txid: Some(txid), + .. + }) => { + format!("Refund broadcast · {}", truncate_middle(txid, 6, 6)) + } + Some(_) => "Refund broadcasting…".to_string(), + None => "Refundable Swap".to_string(), + }; + let mut item = TransactionListItem::new(direction, &btc_amount, bitcoin_unit) - .with_label("Refundable Swap".to_string()) + .with_label(label) .with_time_ago(time_ago) .with_custom_icon(asset_network_logo("lbtc", "liquid", 40.0)) .with_show_direction_badge(show_direction_badges); @@ -649,6 +683,41 @@ pub fn transaction_detail_view<'a>( .into() } +/// Low/Medium/High fee priority buttons. While an async fee fetch is in +/// flight for a given priority, that button renders with a "…" label and is +/// disabled, so the user can tell something is happening — before this, the +/// buttons silently triggered a slow mempool fetch and the user was left +/// wondering if they had actually been clicked. +fn fee_priority_buttons(pending: Option) -> Element<'static, Message> { + fn one( + label: &'static str, + priority: FeeratePriority, + width: f32, + pending: Option, + ) -> Element<'static, Message> { + if pending == Some(priority) { + // Pending: show "…" and no on_press so the button is visually + // disabled while the async fee fetch resolves. + button::secondary(None, "…") + .width(Length::Fixed(width)) + .into() + } else { + button::secondary(None, label) + .on_press(Message::RefundFeeratePrioritySelected(priority)) + .width(Length::Fixed(width)) + .into() + } + } + + Row::new() + .spacing(5) + .push(one("Low", FeeratePriority::Low, 80.0, pending)) + .push(one("Medium", FeeratePriority::Medium, 100.0, pending)) + .push(one("High", FeeratePriority::High, 80.0, pending)) + .into() +} + +#[allow(clippy::too_many_arguments)] pub fn refundable_detail_view<'a>( refundable: &'a RefundableSwap, fiat_converter: Option, @@ -656,6 +725,8 @@ pub fn refundable_detail_view<'a>( refund_address: &'a form::Value, refund_feerate: &'a form::Value, refunding: bool, + pending_fee_priority: Option, + in_flight: Option<&'a InFlightRefund>, ) -> Element<'a, Message> { let btc_amount = Amount::from_sat(refundable.amount_sat); @@ -666,16 +737,24 @@ pub fn refundable_detail_view<'a>( && !refund_address.value.trim().is_empty() && refund_feerate.valid && !refund_feerate.value.trim().is_empty() - && refund_feerate.value.parse::().is_ok(); + && refund_feerate.value.parse::().is_ok() + && in_flight.is_none(); + + let header_status = match in_flight { + Some(InFlightRefund { + refund_txid: Some(txid), + .. + }) => { + format!("Refund broadcast · {}", truncate_middle(txid, 6, 6)) + } + Some(_) => "Refund broadcasting…".to_string(), + None => "This swap can be refunded".to_string(), + }; Column::new() .spacing(20) .push(Container::new(h3("Refundable Swap")).width(Length::Fill)) - .push( - Column::new() - .push(p1_regular("This swap can be refunded")) - .spacing(10), - ) + .push(Column::new().push(p1_regular(header_status)).spacing(10)) .push( Column::new().spacing(20).push( Column::new() @@ -722,7 +801,40 @@ pub fn refundable_detail_view<'a>( .push( Column::new() .width(Length::FillPortion(2)) - .push(text(&refundable.swap_address)), + // Long taproot swap addresses overflow the + // card otherwise. Show a middle-truncated + // preview with a copy button so the user can + // still grab the full address. + .push( + Row::new() + .align_y(Alignment::Center) + .spacing(8) + .push(Container::new( + scrollable( + text(truncate_middle( + &refundable.swap_address, + 10, + 10, + )) + .size(14), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new() + .width(2) + .scroller_width(2), + )), + )) + .push( + iced::widget::button( + icon::clipboard_icon() + .style(theme::text::secondary), + ) + .on_press(Message::Clipboard( + refundable.swap_address.clone(), + )) + .style(theme::button::transparent_border), + ), + ), ) .spacing(20), ) @@ -759,6 +871,13 @@ pub fn refundable_detail_view<'a>( ) .size(14) .padding(12), + ) + .push( + Row::new().spacing(8).push( + button::transparent_border(None, "Use Vault address") + .on_press(Message::GenerateVaultRefundAddress) + .padding([6, 14]), + ), ), ) .push( @@ -777,31 +896,7 @@ pub fn refundable_detail_view<'a>( .size(14) .padding(12), ) - .push( - Row::new() - .spacing(5) - .push( - button::secondary(None, "Low") - .on_press(Message::RefundFeeratePrioritySelected( - FeeratePriority::Low, - )) - .width(Length::Fixed(80.0)), - ) - .push( - button::secondary(None, "Medium") - .on_press(Message::RefundFeeratePrioritySelected( - FeeratePriority::Medium, - )) - .width(Length::Fixed(100.0)), - ) - .push( - button::secondary(None, "High") - .on_press(Message::RefundFeeratePrioritySelected( - FeeratePriority::High, - )) - .width(Length::Fixed(80.0)), - ), - ), + .push(fee_priority_buttons(pending_fee_priority)), ), ) .push( @@ -831,3 +926,38 @@ fn detail_back_button() -> Element<'static, Message> { .style(theme::button::transparent) .into() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_middle_short_string_unchanged() { + assert_eq!(truncate_middle("short", 6, 6), "short"); + } + + #[test] + fn truncate_middle_elides_center() { + let long = "bc1p7gznc2zpn7aq3vqd695eml450d2ls33vw65tvwd77x936jquadnsp7ff6v"; + assert_eq!(truncate_middle(long, 6, 6), "bc1p7g…p7ff6v"); + } + + #[test] + fn refundables_gated_out_of_usdt_filter() { + // Contract guard: refundables are BTC→L-BTC swap refunds, so they + // should only surface under All / L-BTC. Regression from prior bug + // where they appeared under the USDt tab. + for (filter, expected) in [ + (AssetFilter::All, true), + (AssetFilter::LbtcOnly, true), + (AssetFilter::UsdtOnly, false), + ] { + let show = matches!(filter, AssetFilter::All | AssetFilter::LbtcOnly); + assert_eq!( + show, expected, + "refundables visibility for filter {:?}", + filter + ); + } + } +} diff --git a/coincube-gui/src/app/view/message.rs b/coincube-gui/src/app/view/message.rs index 89b052c66..6200f0204 100644 --- a/coincube-gui/src/app/view/message.rs +++ b/coincube-gui/src/app/view/message.rs @@ -71,6 +71,11 @@ pub enum Message { RefundFeerateEdited(String), RefundFeeratePrioritySelected(FeeratePriority), SubmitRefund, + /// Pull a fresh native-Bitcoin receive address from the Vault wallet and + /// drop it into the refund address input. This routes through the existing + /// `daemon.get_new_address()` path so no address-derivation logic is + /// duplicated here. + GenerateVaultRefundAddress, SelectPayment(OutPoint), Label(Vec, LabelMessage), NextReceiveAddress, diff --git a/coincube-gui/test_assets/global_settings.json b/coincube-gui/test_assets/global_settings.json index 6b86d25f7..54f843a83 100644 --- a/coincube-gui/test_assets/global_settings.json +++ b/coincube-gui/test_assets/global_settings.json @@ -77,5 +77,8 @@ "width": 1248.0, "height": 688.0 }, - "developer_mode": false + "developer_mode": false, + "account_tier": "free", + "theme_mode": "dark", + "show_direction_badges": true } \ No newline at end of file diff --git a/docs/BREEZ_BTC_RECEIVE.md b/docs/BREEZ_BTC_RECEIVE.md new file mode 100644 index 000000000..7a524b295 --- /dev/null +++ b/docs/BREEZ_BTC_RECEIVE.md @@ -0,0 +1,63 @@ +# BTC onchain receive in the Liquid wallet + +## Two wallets, two "Bitcoin" flows + +Coincube ships two distinct wallets: + +- **Vault** — native Bitcoin. Address derivation, coin selection, PSBT signing, descriptor-based. Code lives under [coincube-gui/src/app/state/vault/](../coincube-gui/src/app/state/vault/). **This doc does not cover the Vault.** +- **Liquid wallet** — Liquid-native, powered by the Breez Liquid SDK. Code lives under [coincube-gui/src/app/state/liquid/](../coincube-gui/src/app/state/liquid/) and [coincube-gui/src/app/breez/](../coincube-gui/src/app/breez/). + +The Liquid wallet's Receive screen has a "Bitcoin" tab. That tab is **not native Bitcoin** — it returns a Boltz-style swap address where incoming BTC is converted to L-BTC. When writing code, comments, docs or user-facing copy, always disambiguate which wallet you're talking about. + +## The BTC → L-BTC swap: what can go wrong + +The swap has dynamic minimum/maximum amounts set by Boltz, and the app must respect them. Two failure modes matter: + +1. **Out-of-range deposit.** If the user sends BTC below the current min or above the current max, the swap cannot settle and the funds become refundable. +2. **Swap failure / timeout.** Even an in-range deposit can fail (e.g., Boltz is down, lockup tx doesn't confirm in time). Funds also become refundable. + +In both cases the SDK returns the swap from [`list_refundables()`](https://github.com/breez/breez-sdk-liquid) and the user must broadcast a BTC refund transaction to recover the funds. + +## Dynamic limits + +- Fetched via [`BreezClient::fetch_onchain_limits`](../coincube-gui/src/app/breez/client.rs). **Never hardcode** (there is no 25k sat minimum baked into this app). +- Cached in `LiquidReceive::onchain_receive_limits: Option<(u64, u64)>` in [coincube-gui/src/app/state/liquid/receive.rs](../coincube-gui/src/app/state/liquid/receive.rs). +- Fetched eagerly inside `LiquidReceive::fetch_limits()` so the min/max warning box is visible before the user can generate an address. The generate button is replaced with a "Fetching swap limits…" indicator while limits are still loading. + +## Refundable swap discovery + +- [`BreezClient::list_refundables`](../coincube-gui/src/app/breez/client.rs) wraps `LiquidSdk::list_refundables`. +- `LiquidTransactions::reload()` pulls this on panel load. +- Event-driven refresh: [coincube-gui/src/app/mod.rs](../coincube-gui/src/app/mod.rs) `App::refresh_refundables_task()` is a debounced helper (30s) invoked from `SdkEvent::PaymentFailed`, `PaymentRefundable`, `PaymentRefundPending`, `PaymentRefunded`, and `Synced`. The result is routed directly to `LiquidTransactions` via an explicit `Message::RefundablesLoaded` arm in `App::update`, so the panel updates even when it is not currently visible. +- For swaps that expired while the app was offline, the `Synced` arm fires `list_refundables` after the initial sync so they still surface without user action. + +## The in-app refund flow + +1. The user opens Transactions → refundable card (list row labeled "Refundable Swap"). Refundables only show under the **All** and **L-BTC** asset filters — they never appear under USDt. +2. Detail view [coincube-gui/src/app/view/liquid/transactions.rs](../coincube-gui/src/app/view/liquid/transactions.rs) `refundable_detail_view` shows the swap address (middle-truncated + copy button), amount, and a refund form. +3. The user either pastes a BTC address or taps **Use Vault address** which routes `Message::GenerateVaultRefundAddress` → `daemon.get_new_address()` → `Message::RefundAddressEdited`. This reuses the Vault's existing fresh-address derivation rather than duplicating any descriptor logic. +4. Fee rate is populated via Low/Medium/High priority buttons. Primary source is the local `FeeEstimator`; if it errors the GUI falls back to `BreezClient::recommended_fees()` (SDK → Esplora/Electrum). The pressed button shows a "…" label while the async fetch is in flight. +5. On submit, `LiquidTransactions::in_flight_refunds` optimistically records the swap so the card stays visible with a "Refund broadcasting…" banner. On success the banner becomes "Refund broadcast · txid …". +6. `RefundablesLoaded` reconciles: when the SDK no longer returns a swap we previously recorded in `in_flight_refunds`, we drop the local entry. On `RefundCompleted(Err)` we clear any local entry without a txid so a failed attempt doesn't leave a stale banner. + +## Status model + +[coincube-gui/src/app/breez/swap_status.rs](../coincube-gui/src/app/breez/swap_status.rs) defines `BtcSwapReceiveStatus` — an enum that maps the SDK's raw `PaymentState` into the UI lifecycle (`AwaitingDeposit`, `PendingConfirmation`, `PendingSwapCompletion`, `WaitingFeeAcceptance`, `Refundable`, `Refunding`, `Refunded`, `Completed`, `Failed`). + +Use `classify_payment(&payment, &refundable_swap_addresses)` to get the status for a `Payment`. The second argument is needed because a `Payment` in `PaymentState::Failed` is only effectively refundable if the SDK still returns it from `list_refundables` — otherwise it's genuinely terminal. + +When the SDK adds a new `PaymentState` or `SdkEvent`, extend `classify_payment` and add a matching test in the `#[cfg(test)]` block in the same file. Don't spread the mapping logic across call sites. + +## Logging + +All refund/limit/swap activity logs under `target: "breez_swap"`. `truncate_addr` in [coincube-gui/src/app/breez/client.rs](../coincube-gui/src/app/breez/client.rs) keeps only the first and last 6 chars of any address-like value — use it when logging so logs remain debuggable without leaking full on-chain identifiers. Never log seeds, xprivs, or full addresses from this target. + +## Where to extend + +| Change | File | +|---|---| +| New SDK wrapper method | [coincube-gui/src/app/breez/client.rs](../coincube-gui/src/app/breez/client.rs) | +| New `SdkEvent` handling | [coincube-gui/src/app/mod.rs](../coincube-gui/src/app/mod.rs) `Message::BreezEvent` arm | +| New lifecycle state | [coincube-gui/src/app/breez/swap_status.rs](../coincube-gui/src/app/breez/swap_status.rs) | +| Receive UI copy / limits display | [coincube-gui/src/app/view/liquid/receive.rs](../coincube-gui/src/app/view/liquid/receive.rs) | +| Refund UI / in-flight banner | [coincube-gui/src/app/view/liquid/transactions.rs](../coincube-gui/src/app/view/liquid/transactions.rs) + [coincube-gui/src/app/state/liquid/transactions.rs](../coincube-gui/src/app/state/liquid/transactions.rs) | From b5549d90ec584eb965273bb244f1346030e012a6 Mon Sep 17 00:00:00 2001 From: SatoshiSound Date: Tue, 14 Apr 2026 11:19:34 +0100 Subject: [PATCH 2/8] cleanup --- coincube-gui/src/app/breez/mod.rs | 1 - coincube-gui/src/app/mod.rs | 31 +++++++++-- .../src/app/state/liquid/transactions.rs | 54 ++++++++++++++----- coincube-gui/src/app/view/message.rs | 1 + 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/coincube-gui/src/app/breez/mod.rs b/coincube-gui/src/app/breez/mod.rs index 6dc05ad9c..135073039 100644 --- a/coincube-gui/src/app/breez/mod.rs +++ b/coincube-gui/src/app/breez/mod.rs @@ -5,7 +5,6 @@ pub mod swap_status; pub use client::BreezClient; pub use config::BreezConfig; -pub use swap_status::{classify_payment, classify_refundable, BtcSwapReceiveStatus}; // Re-export Breez SDK response types pub use breez_sdk_liquid::prelude::{GetInfoResponse, ReceivePaymentResponse, SendPaymentResponse}; diff --git a/coincube-gui/src/app/mod.rs b/coincube-gui/src/app/mod.rs index 416cb3f62..334cd761f 100644 --- a/coincube-gui/src/app/mod.rs +++ b/coincube-gui/src/app/mod.rs @@ -586,6 +586,10 @@ pub struct App { /// freshly-refundable swap surfaces without user action but long enough to /// avoid noisy churn. last_refundables_fetch: Option, + /// True while a `refresh_refundables_task()` poll is awaiting its result. + /// Prevents duplicate concurrent SDK calls when several BreezEvents arrive + /// in quick succession. Cleared in the `RefundablesLoaded` handler. + refundables_fetch_in_flight: bool, } /// Returns true when a `DaemonError` indicates the daemon process is no longer @@ -722,6 +726,7 @@ impl App { ), toasted_incoming_waiting_tx_ids: VecDeque::with_capacity(16), last_refundables_fetch: None, + refundables_fetch_in_flight: false, }, cmd, ) @@ -797,6 +802,7 @@ impl App { ), toasted_incoming_waiting_tx_ids: VecDeque::with_capacity(16), last_refundables_fetch: None, + refundables_fetch_in_flight: false, }, cmd, ) @@ -1268,13 +1274,21 @@ impl App { /// still see it the next time they navigate or glance at the app. fn refresh_refundables_task(&mut self) -> Task { const DEBOUNCE: std::time::Duration = std::time::Duration::from_secs(30); - let now = std::time::Instant::now(); + // Skip if a previous fetch is still in flight — otherwise a burst of + // BreezEvents would launch several concurrent `list_refundables()` + // calls before any of them returned. + if self.refundables_fetch_in_flight { + return Task::none(); + } + // Debounce against the timestamp of the last *successful* fetch. On + // failure we leave `last_refundables_fetch` unchanged so the next + // event can retry immediately instead of being suppressed for 30s. if let Some(prev) = self.last_refundables_fetch { - if now.duration_since(prev) < DEBOUNCE { + if std::time::Instant::now().duration_since(prev) < DEBOUNCE { return Task::none(); } } - self.last_refundables_fetch = Some(now); + self.refundables_fetch_in_flight = true; let client = self.breez_client.clone(); Task::perform( async move { client.list_refundables().await }, @@ -2050,7 +2064,16 @@ impl App { // handlers above) land on the correct panel even when the user is // sitting on a different screen. Otherwise the result would be // dropped into whatever panel happens to be current. - msg @ Message::RefundablesLoaded(_) => { + msg @ Message::RefundablesLoaded(_) | msg @ Message::RefundCompleted(_) => { + if let Message::RefundablesLoaded(result) = &msg { + // Clear the in-flight guard regardless of outcome, but + // only advance the debounce timestamp on success so a + // failed poll doesn't suppress retries for 30s. + self.refundables_fetch_in_flight = false; + if result.is_ok() { + self.last_refundables_fetch = Some(std::time::Instant::now()); + } + } return self.panels.liquid_transactions.update( self.daemon.clone(), &self.cache, diff --git a/coincube-gui/src/app/state/liquid/transactions.rs b/coincube-gui/src/app/state/liquid/transactions.rs index af5c22432..e6075ad24 100644 --- a/coincube-gui/src/app/state/liquid/transactions.rs +++ b/coincube-gui/src/app/state/liquid/transactions.rs @@ -30,6 +30,13 @@ pub struct InFlightRefund { pub submitted_at: Instant, } +/// How long an optimistic in-flight refund (no txid yet) is preserved across +/// `RefundablesLoaded` reconciliation even when the SDK no longer returns the +/// swap. Covers the race where a background poll completes between our +/// `refund_onchain_tx` broadcast and the corresponding `RefundCompleted` +/// message. +const IN_FLIGHT_GRACE: std::time::Duration = std::time::Duration::from_secs(60); + #[derive(Debug)] enum LiquidTransactionsModal { None, @@ -100,15 +107,30 @@ impl LiquidTransactions { self.pending_fee_priority } - #[cfg(test)] - pub fn test_reconcile_in_flight(&mut self, refundables: Vec) { + fn reconcile_in_flight(&mut self, refundables: Vec) { let returned: std::collections::HashSet<&String> = refundables.iter().map(|r| &r.swap_address).collect(); - self.in_flight_refunds - .retain(|addr, _| returned.contains(addr)); + let now = Instant::now(); + self.in_flight_refunds.retain(|addr, entry| { + if returned.contains(addr) { + return true; + } + // Swap is no longer listed by the SDK. Normally that means the + // refund broadcast propagated and the swap can be dropped. But + // an optimistic entry (refund_txid == None) that's still within + // the grace window may simply be waiting for `RefundCompleted` + // to land — don't erase it yet, or the "Refund broadcasting…" + // banner would disappear before the user sees it. + entry.refund_txid.is_none() && now.duration_since(entry.submitted_at) < IN_FLIGHT_GRACE + }); self.refundables = refundables; } + #[cfg(test)] + pub fn test_reconcile_in_flight(&mut self, refundables: Vec) { + self.reconcile_in_flight(refundables); + } + pub fn asset_filter(&self) -> AssetFilter { self.asset_filter } @@ -277,14 +299,9 @@ impl State for LiquidTransactions { // Reconcile in-flight refunds with the freshly-fetched list. // A swap leaves `list_refundables` once the SDK observes our // refund tx, so anything tracked locally that is no longer in - // the SDK's list is complete and can be dropped from the - // "broadcasting" banner. This prevents a stale "Refund - // broadcasting…" from sticking around forever. - let returned: std::collections::HashSet<&String> = - refundables.iter().map(|r| &r.swap_address).collect(); - self.in_flight_refunds - .retain(|addr, _| returned.contains(addr)); - self.refundables = refundables; + // the SDK's list and has an observed broadcast (or has + // exceeded the grace window) can be dropped. + self.reconcile_in_flight(refundables); Task::none() } Message::RefundablesLoaded(Err(e)) => { @@ -300,6 +317,7 @@ impl State for LiquidTransactions { self.selected_payment = None; self.refund_address = form::Value::default(); self.refund_feerate = form::Value::default(); + self.pending_fee_priority = None; Task::none() } Message::View(view::Message::Reload) => self.reload(None, None), @@ -309,6 +327,7 @@ impl State for LiquidTransactions { self.modal = LiquidTransactionsModal::None; self.refund_address = form::Value::default(); self.refund_feerate = form::Value::default(); + self.pending_fee_priority = None; Task::none() } Message::View(view::Message::PreselectPayment(payment)) => { @@ -425,6 +444,15 @@ impl State for LiquidTransactions { self.pending_fee_priority = None; Task::none() } + Message::View(view::Message::RefundFeeratePriorityFailed(err)) => { + // Async fee fetch failed — clear the spinner so the pressed + // button becomes interactive again, then surface the error. + // ShowError is intercepted by App::update into a toast and + // never reaches here, so we must clear the spinner ourselves + // before forwarding. + self.pending_fee_priority = None; + Task::done(Message::View(view::Message::ShowError(err))) + } Message::View(view::Message::RefundFeeratePrioritySelected(priority)) => { // Record which button was pressed so the view can render a // "…" spinner while the async fee fetch is in flight. @@ -464,7 +492,7 @@ impl State for LiquidTransactions { if let Some(rate) = rate { Message::View(view::Message::RefundFeerateEdited(rate.to_string())) } else { - Message::View(view::Message::ShowError( + Message::View(view::Message::RefundFeeratePriorityFailed( "Failed to fetch fee rate".to_string(), )) } diff --git a/coincube-gui/src/app/view/message.rs b/coincube-gui/src/app/view/message.rs index 6200f0204..af751d016 100644 --- a/coincube-gui/src/app/view/message.rs +++ b/coincube-gui/src/app/view/message.rs @@ -70,6 +70,7 @@ pub enum Message { RefundAddressValidated(bool), RefundFeerateEdited(String), RefundFeeratePrioritySelected(FeeratePriority), + RefundFeeratePriorityFailed(String), SubmitRefund, /// Pull a fresh native-Bitcoin receive address from the Vault wallet and /// drop it into the refund address input. This routes through the existing From 76df4f90bd32fa52199b29b29ca65435cdba982d Mon Sep 17 00:00:00 2001 From: SatoshiSound Date: Tue, 14 Apr 2026 15:59:01 +0100 Subject: [PATCH 3/8] cleanup --- coincube-gui/src/app/message.rs | 7 ++ coincube-gui/src/app/mod.rs | 37 ++++-- coincube-gui/src/app/state/liquid/overview.rs | 1 + coincube-gui/src/app/state/liquid/send.rs | 1 + .../src/app/state/liquid/transactions.rs | 114 +++++++++++++++--- .../src/app/view/liquid/transactions.rs | 107 ++++++++-------- coincube-gui/src/app/view/message.rs | 6 + 7 files changed, 188 insertions(+), 85 deletions(-) diff --git a/coincube-gui/src/app/message.rs b/coincube-gui/src/app/message.rs index c17961be6..94e113fd2 100644 --- a/coincube-gui/src/app/message.rs +++ b/coincube-gui/src/app/message.rs @@ -77,6 +77,13 @@ pub enum Message { Export(ImportExportMessage), PaymentsLoaded(Result, BreezError>), RefundablesLoaded(Result, BreezError>), + /// Result of a debounced background poll started by + /// `App::refresh_refundables_task`. Distinct from `RefundablesLoaded` + /// (which is produced by manual panel reloads) so that only poll + /// responses touch the App's debounce/in-flight tracking. A reload + /// response racing ahead of a poll must not clear the in-flight flag, + /// or a second concurrent `list_refundables()` could be launched. + RefundablesPolled(Result, BreezError>), RefundCompleted(Result), BreezInfo(Result), BreezEvent(breez_sdk_liquid::prelude::SdkEvent), diff --git a/coincube-gui/src/app/mod.rs b/coincube-gui/src/app/mod.rs index 334cd761f..b7a94cf41 100644 --- a/coincube-gui/src/app/mod.rs +++ b/coincube-gui/src/app/mod.rs @@ -1265,8 +1265,10 @@ impl App { /// Kick off a background `list_refundables()` poll, debounced so that /// SDK events (which can fire several times a second during sync) don't - /// hammer the SDK. Result is routed back to `Message::RefundablesLoaded`, - /// which `LiquidTransactions::update()` already handles. + /// hammer the SDK. Result comes back as `Message::RefundablesPolled` — + /// a variant distinct from `RefundablesLoaded` (which manual panel + /// reloads produce) so that only poll responses touch the App's + /// debounce and in-flight fields. /// /// The Transactions panel itself fetches refundables on every reload() /// too — this debounced helper covers the case where the user is sitting @@ -1292,7 +1294,7 @@ impl App { let client = self.breez_client.clone(); Task::perform( async move { client.list_refundables().await }, - Message::RefundablesLoaded, + Message::RefundablesPolled, ) } @@ -2064,16 +2066,27 @@ impl App { // handlers above) land on the correct panel even when the user is // sitting on a different screen. Otherwise the result would be // dropped into whatever panel happens to be current. - msg @ Message::RefundablesLoaded(_) | msg @ Message::RefundCompleted(_) => { - if let Message::RefundablesLoaded(result) = &msg { - // Clear the in-flight guard regardless of outcome, but - // only advance the debounce timestamp on success so a - // failed poll doesn't suppress retries for 30s. - self.refundables_fetch_in_flight = false; - if result.is_ok() { - self.last_refundables_fetch = Some(std::time::Instant::now()); - } + Message::RefundablesPolled(result) => { + // Poll response: clear the in-flight guard regardless of + // outcome, but only advance the debounce timestamp on + // success so a failed poll doesn't suppress retries for 30s. + // We intentionally *don't* touch these fields for a manual + // reload response — see the `RefundablesLoaded` arm below. + self.refundables_fetch_in_flight = false; + if result.is_ok() { + self.last_refundables_fetch = Some(std::time::Instant::now()); } + // Forward the payload to LiquidTransactions through the + // panel's regular handler. The panel's reconciliation logic + // is origin-agnostic, so a poll result is converted to a + // `RefundablesLoaded` for it. + return self.panels.liquid_transactions.update( + self.daemon.clone(), + &self.cache, + Message::RefundablesLoaded(result), + ); + } + msg @ Message::RefundablesLoaded(_) | msg @ Message::RefundCompleted(_) => { return self.panels.liquid_transactions.update( self.daemon.clone(), &self.cache, diff --git a/coincube-gui/src/app/state/liquid/overview.rs b/coincube-gui/src/app/state/liquid/overview.rs index fa0586b10..2f4df0c9f 100644 --- a/coincube-gui/src/app/state/liquid/overview.rs +++ b/coincube-gui/src/app/state/liquid/overview.rs @@ -114,6 +114,7 @@ impl State for LiquidOverview { fiat_converter, cache.bitcoin_unit, usdt_asset_id(self.breez_client.network()).unwrap_or(""), + &[], ), ) } else { diff --git a/coincube-gui/src/app/state/liquid/send.rs b/coincube-gui/src/app/state/liquid/send.rs index 0b95e57c9..1abed941e 100644 --- a/coincube-gui/src/app/state/liquid/send.rs +++ b/coincube-gui/src/app/state/liquid/send.rs @@ -397,6 +397,7 @@ impl State for LiquidSend { fiat_converter, cache.bitcoin_unit, usdt_asset_id(self.breez_client.network()).unwrap_or(""), + &[], ), ) } else { diff --git a/coincube-gui/src/app/state/liquid/transactions.rs b/coincube-gui/src/app/state/liquid/transactions.rs index e6075ad24..4533db31e 100644 --- a/coincube-gui/src/app/state/liquid/transactions.rs +++ b/coincube-gui/src/app/state/liquid/transactions.rs @@ -107,9 +107,9 @@ impl LiquidTransactions { self.pending_fee_priority } - fn reconcile_in_flight(&mut self, refundables: Vec) { - let returned: std::collections::HashSet<&String> = - refundables.iter().map(|r| &r.swap_address).collect(); + fn reconcile_in_flight(&mut self, mut refundables: Vec) { + let returned: std::collections::HashSet = + refundables.iter().map(|r| r.swap_address.clone()).collect(); let now = Instant::now(); self.in_flight_refunds.retain(|addr, entry| { if returned.contains(addr) { @@ -123,6 +123,19 @@ impl LiquidTransactions { // banner would disappear before the user sees it. entry.refund_txid.is_none() && now.duration_since(entry.submitted_at) < IN_FLIGHT_GRACE }); + // Carry forward any locally-known RefundableSwap whose address still + // has a grace-window in_flight entry but that the SDK dropped. The + // view iterates `self.refundables` to render cards and only uses + // `in_flight_refunds` for extra metadata, so without this the + // "Refund broadcasting…" card would vanish the instant the SDK + // stopped listing the swap, defeating the grace window. + for prev in std::mem::take(&mut self.refundables) { + if !returned.contains(&prev.swap_address) + && self.in_flight_refunds.contains_key(&prev.swap_address) + { + refundables.push(prev); + } + } self.refundables = refundables; } @@ -180,6 +193,11 @@ impl LiquidTransactions { impl State for LiquidTransactions { fn view<'a>(&'a self, menu: &'a Menu, cache: &'a Cache) -> Element<'a, view::Message> { let fiat_converter = cache.fiat_price.as_ref().and_then(|p| p.try_into().ok()); + let refundable_swap_addresses: Vec = self + .refundables + .iter() + .map(|r| r.swap_address.clone()) + .collect(); let content = if let Some(payment) = &self.selected_payment { view::dashboard( menu, @@ -189,6 +207,7 @@ impl State for LiquidTransactions { fiat_converter, cache.bitcoin_unit, usdt_asset_id(self.breez_client.network()).unwrap_or(""), + &refundable_swap_addresses, ), ) } else if let Some(refundable) = &self.selected_refundable { @@ -489,16 +508,30 @@ impl State for LiquidTransactions { } }, move |rate: Option| { - if let Some(rate) = rate { - Message::View(view::Message::RefundFeerateEdited(rate.to_string())) - } else { - Message::View(view::Message::RefundFeeratePriorityFailed( - "Failed to fetch fee rate".to_string(), - )) - } + // Tag the result with the priority that kicked off + // the fetch. The handler in the update loop will + // discard it if `pending_fee_priority` has moved on. + Message::View(view::Message::RefundFeeratePriorityResolved(priority, rate)) }, ) } + Message::View(view::Message::RefundFeeratePriorityResolved(priority, rate)) => { + // Ignore stale responses: if the user typed a custom feerate + // (clearing `pending_fee_priority`) or clicked a different + // priority button, this in-flight result must not clobber + // their newer input. + if self.pending_fee_priority != Some(priority) { + return Task::none(); + } + match rate { + Some(rate) => Task::done(Message::View(view::Message::RefundFeerateEdited( + rate.to_string(), + ))), + None => Task::done(Message::View(view::Message::RefundFeeratePriorityFailed( + "Failed to fetch fee rate".to_string(), + ))), + } + } Message::View(view::Message::GenerateVaultRefundAddress) => { // Reuse the Vault wallet's existing fresh-address derivation // (`daemon.get_new_address()`). This intentionally does NOT @@ -579,13 +612,16 @@ impl State for LiquidTransactions { self.selected_refundable = None; self.refund_address = form::Value::default(); self.refund_feerate = form::Value::default(); - Task::batch(vec![ - Task::done(Message::View(view::Message::ShowToast( - log::Level::Info, - format!("Refund broadcast · {}", txid.get(..10).unwrap_or(&txid)), - ))), - Task::done(Message::View(view::Message::Close)), - ]) + // Do NOT emit view::Message::Close here: it routes globally + // through App's panel router and would land on whatever + // panel is currently active, resetting unrelated state if + // the user navigated away while the refund was broadcasting. + // The local field clears above already collapse this panel + // back to the transactions list on the next render. + Task::done(Message::View(view::Message::ShowToast( + log::Level::Info, + format!("Refund broadcast · {}", txid.get(..10).unwrap_or(&txid)), + ))) } Message::RefundCompleted(Err(e)) => { self.refunding = false; @@ -685,4 +721,48 @@ mod tests { state.test_reconcile_in_flight(vec![sample_refundable("bc1q_active")]); assert!(state.in_flight_refunds.contains_key("bc1q_active")); } + + #[test] + fn in_flight_card_carried_forward_when_sdk_drops_optimistic_swap() { + // Regression: grace window preserves the in_flight entry *and* the + // RefundableSwap, so the view (which iterates self.refundables) keeps + // rendering the "Refund broadcasting…" card until RefundCompleted. + let mut state = new_state(); + state.refundables = vec![sample_refundable("bc1q_racing")]; + state.in_flight_refunds.insert( + "bc1q_racing".to_string(), + InFlightRefund { + refund_txid: None, + submitted_at: Instant::now(), + }, + ); + + // SDK poll races ahead of RefundCompleted and no longer lists the swap. + state.test_reconcile_in_flight(vec![]); + + assert!(state.in_flight_refunds.contains_key("bc1q_racing")); + assert_eq!(state.refundables.len(), 1); + assert_eq!(state.refundables[0].swap_address, "bc1q_racing"); + } + + #[test] + fn in_flight_card_dropped_once_entry_removed() { + // Carry-forward is tied to in_flight presence: once the entry is + // dropped (e.g. txid set + absent from SDK list), the refundable + // must also disappear. + let mut state = new_state(); + state.refundables = vec![sample_refundable("bc1q_done")]; + state.in_flight_refunds.insert( + "bc1q_done".to_string(), + InFlightRefund { + refund_txid: Some("deadbeef".to_string()), + submitted_at: Instant::now(), + }, + ); + + state.test_reconcile_in_flight(vec![]); + + assert!(!state.in_flight_refunds.contains_key("bc1q_done")); + assert!(state.refundables.is_empty()); + } } diff --git a/coincube-gui/src/app/view/liquid/transactions.rs b/coincube-gui/src/app/view/liquid/transactions.rs index 050a632e4..db04d6950 100644 --- a/coincube-gui/src/app/view/liquid/transactions.rs +++ b/coincube-gui/src/app/view/liquid/transactions.rs @@ -24,6 +24,7 @@ use iced::{ use coincube_ui::image::asset_network_logo; use crate::app::breez::assets::{format_usdt_display, USDT_PRECISION}; +use crate::app::breez::swap_status::{classify_payment, BtcSwapReceiveStatus}; use crate::app::menu::Menu; use crate::app::state::liquid::transactions::{AssetFilter, InFlightRefund}; use crate::app::view::message::{FeeratePriority, Message}; @@ -31,6 +32,45 @@ use crate::app::view::FiatAmountConverter; use crate::export::ImportExportMessage; use crate::utils::{format_time_ago, format_timestamp}; +/// Styled status cell for the payment detail card. For BTC onchain swap +/// payments this routes through `classify_payment`, which gives us the full +/// Boltz lifecycle (including the `Failed` → `Refundable` upgrade when the SDK +/// still lists the swap as refundable and the `Pending` → `PendingConfirmation` +/// vs `PendingSwapCompletion` split once the lockup tx is seen). Direct Liquid +/// and Lightning payments fall back to raw SDK labels, because swap-specific +/// mappings like "Complete Send → Refunded" don't apply to them. +fn payment_status_text( + payment: &Payment, + refundable_swap_addresses: &[String], +) -> Element<'static, Message> { + if matches!(payment.details, PaymentDetails::Bitcoin { .. }) { + let status = classify_payment(payment, refundable_swap_addresses); + let style = match status { + BtcSwapReceiveStatus::Completed | BtcSwapReceiveStatus::Refunded => { + theme::text::success + } + BtcSwapReceiveStatus::Failed | BtcSwapReceiveStatus::Refundable => { + theme::text::destructive + } + _ => theme::text::secondary, + }; + return text(status.label()).style(style).into(); + } + + match payment.status { + PaymentState::Complete => text("Complete").style(theme::text::success).into(), + PaymentState::Pending => text("Pending").style(theme::text::secondary).into(), + PaymentState::Created => text("Created").style(theme::text::secondary).into(), + PaymentState::Failed => text("Failed").style(theme::text::destructive).into(), + PaymentState::TimedOut => text("Timed Out").style(theme::text::destructive).into(), + PaymentState::Refundable => text("Refundable").style(theme::text::destructive).into(), + PaymentState::RefundPending => text("Refund Pending").style(theme::text::secondary).into(), + PaymentState::WaitingFeeAcceptance => text("Waiting Fee Acceptance") + .style(theme::text::secondary) + .into(), + } +} + /// Truncate a long on-chain address / txid like `bc1p7g…7ff6v` so it fits /// inside a card without overflowing. Used for display only. fn truncate_middle(s: &str, prefix_len: usize, suffix_len: usize) -> String { @@ -341,6 +381,7 @@ pub fn transaction_detail_view<'a>( fiat_converter: Option, bitcoin_unit: coincube_ui::component::amount::BitcoinDisplayUnit, usdt_id: &str, + refundable_swap_addresses: &[String], ) -> Element<'a, Message> { let is_receive = matches!(payment.payment_type, PaymentType::Receive); let usdt_str = usdt_amount_str(payment, usdt_id); @@ -474,34 +515,11 @@ pub fn transaction_detail_view<'a>( .width(Length::FillPortion(1)) .push(text("Status").bold()), ) - .push(Column::new().width(Length::FillPortion(2)).push( - match payment.status { - PaymentState::Complete => { - text("Complete").style(theme::text::success) - } - PaymentState::Pending => { - text("Pending").style(theme::text::secondary) - } - PaymentState::Created => { - text("Created").style(theme::text::secondary) - } - PaymentState::Failed => { - text("Failed").style(theme::text::destructive) - } - PaymentState::TimedOut => { - text("Timed Out").style(theme::text::destructive) - } - PaymentState::Refundable => { - text("Refundable").style(theme::text::destructive) - } - PaymentState::RefundPending => { - text("Refund Pending").style(theme::text::secondary) - } - PaymentState::WaitingFeeAcceptance => { - text("Waiting Fee Acceptance").style(theme::text::secondary) - } - }, - )) + .push( + Column::new() + .width(Length::FillPortion(2)) + .push(payment_status_text(payment, refundable_swap_addresses)), + ) .spacing(20), ) .push( @@ -620,34 +638,11 @@ pub fn transaction_detail_view<'a>( .width(Length::FillPortion(1)) .push(text("Status").bold()), ) - .push(Column::new().width(Length::FillPortion(2)).push( - match payment.status { - PaymentState::Complete => { - text("Complete").style(theme::text::success) - } - PaymentState::Pending => { - text("Pending").style(theme::text::secondary) - } - PaymentState::Created => { - text("Created").style(theme::text::secondary) - } - PaymentState::Failed => { - text("Failed").style(theme::text::destructive) - } - PaymentState::TimedOut => { - text("Timed Out").style(theme::text::destructive) - } - PaymentState::Refundable => { - text("Refundable").style(theme::text::destructive) - } - PaymentState::RefundPending => { - text("Refund Pending").style(theme::text::secondary) - } - PaymentState::WaitingFeeAcceptance => { - text("Waiting Fee Acceptance").style(theme::text::secondary) - } - }, - )) + .push( + Column::new() + .width(Length::FillPortion(2)) + .push(payment_status_text(payment, refundable_swap_addresses)), + ) .spacing(20), ) .push( diff --git a/coincube-gui/src/app/view/message.rs b/coincube-gui/src/app/view/message.rs index af751d016..c27ebbab4 100644 --- a/coincube-gui/src/app/view/message.rs +++ b/coincube-gui/src/app/view/message.rs @@ -71,6 +71,12 @@ pub enum Message { RefundFeerateEdited(String), RefundFeeratePrioritySelected(FeeratePriority), RefundFeeratePriorityFailed(String), + /// Result of the async fee-rate fetch spawned by + /// `RefundFeeratePrioritySelected`. Carries the originating priority so + /// the handler can ignore stale responses — e.g. when the user typed a + /// custom feerate or picked a different priority before this one + /// returned. `Some(rate)` = success, `None` = fetch failed. + RefundFeeratePriorityResolved(FeeratePriority, Option), SubmitRefund, /// Pull a fresh native-Bitcoin receive address from the Vault wallet and /// drop it into the refund address input. This routes through the existing From 2648878fe4c4a2a7760596395523e4661dc0380a Mon Sep 17 00:00:00 2001 From: SatoshiSound Date: Tue, 14 Apr 2026 17:18:34 +0100 Subject: [PATCH 4/8] cleanup --- coincube-gui/src/app/breez/swap_status.rs | 56 ++++++++++++++++--- .../src/app/state/liquid/transactions.rs | 1 + .../src/app/view/liquid/transactions.rs | 7 ++- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/coincube-gui/src/app/breez/swap_status.rs b/coincube-gui/src/app/breez/swap_status.rs index 6dff1efa4..79a89b3ca 100644 --- a/coincube-gui/src/app/breez/swap_status.rs +++ b/coincube-gui/src/app/breez/swap_status.rs @@ -80,7 +80,12 @@ impl BtcSwapReceiveStatus { /// callers pass `refundable_swap_addresses` to resolve it. /// - `TimedOut` → `Failed` /// - `Complete` (receive) → `Completed` -/// - `Complete` (send on same swap — the refund leg) → `Refunded` +/// - `Complete` (send, `refund_tx_id` set — the refund leg) → `Refunded` +/// - `Complete` (send, no `refund_tx_id` — an L-BTC→BTC chain swap send, +/// which shares `PaymentDetails::Bitcoin` but is NOT a receive swap) → +/// `Completed`. This enum is scoped to the receive path, so the best we +/// can report for a successful outgoing chain swap is "completed" rather +/// than misreporting it as a refund. /// /// `refundable_swap_addresses` is the set of `swap_address` strings currently /// returned by `BreezClient::list_refundables()`. Pass an empty slice when @@ -115,9 +120,22 @@ pub fn classify_payment(p: &Payment, refundable_swap_addresses: &[String]) -> Bt PaymentState::TimedOut => BtcSwapReceiveStatus::Failed, PaymentState::Complete => match p.payment_type { PaymentType::Receive => BtcSwapReceiveStatus::Completed, - // A Complete send on a BTC swap details means the refund leg has - // confirmed on chain. - PaymentType::Send => BtcSwapReceiveStatus::Refunded, + // A Complete send with `refund_tx_id` set is the refund leg of a + // failed BTC→L-BTC receive swap. A Complete send *without* a + // refund txid is an L-BTC→BTC chain swap send (a different swap + // direction that happens to share PaymentDetails::Bitcoin) — it + // is a successful outgoing payment, not a refund. + PaymentType::Send => { + let is_refund_leg = matches!( + &p.details, + PaymentDetails::Bitcoin { refund_tx_id: Some(_), .. } + ); + if is_refund_leg { + BtcSwapReceiveStatus::Refunded + } else { + BtcSwapReceiveStatus::Completed + } + } }, } } @@ -135,6 +153,15 @@ mod tests { use breez_sdk_liquid::prelude::{Payment, PaymentDetails, PaymentState, PaymentType}; fn btc_payment(status: PaymentState, ptype: PaymentType, lockup: Option) -> Payment { + btc_payment_with_refund(status, ptype, lockup, None) + } + + fn btc_payment_with_refund( + status: PaymentState, + ptype: PaymentType, + lockup: Option, + refund_tx_id: Option, + ) -> Payment { Payment { destination: Some("bc1qtest".into()), tx_id: None, @@ -154,7 +181,7 @@ mod tests { bitcoin_expiration_blockheight: 0, lockup_tx_id: lockup, claim_tx_id: None, - refund_tx_id: None, + refund_tx_id, refund_tx_amount_sat: None, }, } @@ -232,11 +259,26 @@ mod tests { } #[test] - fn complete_send_on_btc_swap_is_refunded() { - let p = btc_payment(PaymentState::Complete, PaymentType::Send, None); + fn complete_send_with_refund_tx_is_refunded() { + // The refund leg of a failed BTC→L-BTC receive swap. + let p = btc_payment_with_refund( + PaymentState::Complete, + PaymentType::Send, + None, + Some("refund-txid".into()), + ); assert_eq!(classify_payment(&p, &[]), BtcSwapReceiveStatus::Refunded); } + #[test] + fn complete_send_without_refund_tx_is_completed() { + // Regression: an L-BTC→BTC chain swap send also uses + // PaymentDetails::Bitcoin + PaymentType::Send, but is NOT a refund. + // Previously classify_payment reported it as Refunded. + let p = btc_payment(PaymentState::Complete, PaymentType::Send, None); + assert_eq!(classify_payment(&p, &[]), BtcSwapReceiveStatus::Completed); + } + #[test] fn waiting_fee_acceptance_maps() { let p = btc_payment( diff --git a/coincube-gui/src/app/state/liquid/transactions.rs b/coincube-gui/src/app/state/liquid/transactions.rs index af5c22432..c92c0d39e 100644 --- a/coincube-gui/src/app/state/liquid/transactions.rs +++ b/coincube-gui/src/app/state/liquid/transactions.rs @@ -182,6 +182,7 @@ impl State for LiquidTransactions { self.refunding, self.pending_fee_priority, self.in_flight_refunds.get(&refundable.swap_address), + cache.has_vault, ), ) } else { diff --git a/coincube-gui/src/app/view/liquid/transactions.rs b/coincube-gui/src/app/view/liquid/transactions.rs index 050a632e4..3329a6eed 100644 --- a/coincube-gui/src/app/view/liquid/transactions.rs +++ b/coincube-gui/src/app/view/liquid/transactions.rs @@ -727,6 +727,7 @@ pub fn refundable_detail_view<'a>( refunding: bool, pending_fee_priority: Option, in_flight: Option<&'a InFlightRefund>, + has_vault: bool, ) -> Element<'a, Message> { let btc_amount = Amount::from_sat(refundable.amount_sat); @@ -872,13 +873,13 @@ pub fn refundable_detail_view<'a>( .size(14) .padding(12), ) - .push( + .push_maybe(has_vault.then(|| { Row::new().spacing(8).push( button::transparent_border(None, "Use Vault address") .on_press(Message::GenerateVaultRefundAddress) .padding([6, 14]), - ), - ), + ) + })), ) .push( Column::new() From 16f6a01f83fa55b3d7abaf7dd056f8a514146360 Mon Sep 17 00:00:00 2001 From: SatoshiSound Date: Tue, 14 Apr 2026 17:32:29 +0100 Subject: [PATCH 5/8] fix formatting issue --- coincube-gui/src/app/breez/swap_status.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coincube-gui/src/app/breez/swap_status.rs b/coincube-gui/src/app/breez/swap_status.rs index 79a89b3ca..2a961282c 100644 --- a/coincube-gui/src/app/breez/swap_status.rs +++ b/coincube-gui/src/app/breez/swap_status.rs @@ -128,7 +128,10 @@ pub fn classify_payment(p: &Payment, refundable_swap_addresses: &[String]) -> Bt PaymentType::Send => { let is_refund_leg = matches!( &p.details, - PaymentDetails::Bitcoin { refund_tx_id: Some(_), .. } + PaymentDetails::Bitcoin { + refund_tx_id: Some(_), + .. + } ); if is_refund_leg { BtcSwapReceiveStatus::Refunded From f2abbcf199f07711fd8d57a7202c1318bc87c953 Mon Sep 17 00:00:00 2001 From: SatoshiSound Date: Tue, 14 Apr 2026 22:08:14 +0100 Subject: [PATCH 6/8] cleanup --- coincube-gui/src/app/message.rs | 10 ++++- coincube-gui/src/app/mod.rs | 9 ++-- .../src/app/state/liquid/transactions.rs | 44 ++++++++++++------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/coincube-gui/src/app/message.rs b/coincube-gui/src/app/message.rs index 94e113fd2..c56fbc9f7 100644 --- a/coincube-gui/src/app/message.rs +++ b/coincube-gui/src/app/message.rs @@ -84,7 +84,15 @@ pub enum Message { /// response racing ahead of a poll must not clear the in-flight flag, /// or a second concurrent `list_refundables()` could be launched. RefundablesPolled(Result, BreezError>), - RefundCompleted(Result), + /// Result of a user-initiated `refund_onchain_tx` call. The `swap_address` + /// is carried alongside the response so the handler can look up the exact + /// `in_flight_refunds` entry that originated this refund — necessary when + /// more than one refund is in flight, since the SDK response itself does + /// not identify the originating swap. + RefundCompleted { + swap_address: String, + result: Result, + }, BreezInfo(Result), BreezEvent(breez_sdk_liquid::prelude::SdkEvent), SettingsSaved, diff --git a/coincube-gui/src/app/mod.rs b/coincube-gui/src/app/mod.rs index b7a94cf41..71c67c0ad 100644 --- a/coincube-gui/src/app/mod.rs +++ b/coincube-gui/src/app/mod.rs @@ -2041,11 +2041,10 @@ impl App { } // Debounced refundables poll — picks up older expired // swaps that didn't emit an explicit refundable event - // while the app was offline. + // while the app was offline. Always enqueued, so this + // arm unconditionally returns. tasks.push(self.refresh_refundables_task()); - if !tasks.is_empty() { - return Task::batch(tasks); - } + return Task::batch(tasks); } _ => { // Other events - just log @@ -2086,7 +2085,7 @@ impl App { Message::RefundablesLoaded(result), ); } - msg @ Message::RefundablesLoaded(_) | msg @ Message::RefundCompleted(_) => { + msg @ Message::RefundablesLoaded(_) | msg @ Message::RefundCompleted { .. } => { return self.panels.liquid_transactions.update( self.daemon.clone(), &self.cache, diff --git a/coincube-gui/src/app/state/liquid/transactions.rs b/coincube-gui/src/app/state/liquid/transactions.rs index 2b1d2e7e8..0a47b8d3a 100644 --- a/coincube-gui/src/app/state/liquid/transactions.rs +++ b/coincube-gui/src/app/state/liquid/transactions.rs @@ -579,6 +579,7 @@ impl State for LiquidTransactions { let breez_client = self.breez_client.clone(); let refund_address = self.refund_address.value.clone(); let fee_rate = self.refund_feerate.value.parse::().unwrap_or(1); + let swap_address_for_msg = swap_address.clone(); Task::perform( async move { @@ -590,24 +591,28 @@ impl State for LiquidTransactions { }) .await }, - Message::RefundCompleted, + move |result| Message::RefundCompleted { + swap_address: swap_address_for_msg.clone(), + result, + }, ) } else { log::error!(target: "refund_debug", "SubmitRefund called but no refundable selected"); Task::none() } } - Message::RefundCompleted(Ok(response)) => { + Message::RefundCompleted { + swap_address, + result: Ok(response), + } => { self.refunding = false; let txid = response.refund_tx_id.clone(); - // Populate the refund_txid on the most recent in-flight entry - // that doesn't yet have one. We don't have the swap_address - // on the response, so match on the missing txid field. - if let Some(entry) = self - .in_flight_refunds - .values_mut() - .find(|r| r.refund_txid.is_none()) - { + // Populate the refund_txid on the exact in-flight entry that + // originated this refund. Looking up by swap_address is + // deterministic even with multiple concurrent refunds — the + // prior `values_mut().find(...)` approach was racy because + // HashMap iteration order is unspecified. + if let Some(entry) = self.in_flight_refunds.get_mut(&swap_address) { entry.refund_txid = Some(txid.clone()); } self.selected_refundable = None; @@ -624,13 +629,20 @@ impl State for LiquidTransactions { format!("Refund broadcast · {}", txid.get(..10).unwrap_or(&txid)), ))) } - Message::RefundCompleted(Err(e)) => { + Message::RefundCompleted { + swap_address, + result: Err(e), + } => { self.refunding = false; - // Drop any in-flight entry that doesn't have a txid — - // submission never reached broadcast, so leaving a stale - // "broadcasting" banner up would lie to the user. - self.in_flight_refunds - .retain(|_, r| r.refund_txid.is_some()); + // Drop the in-flight entry for exactly this swap if it never + // reached broadcast (txid still None). Leaving it up would + // show a stale "broadcasting" banner for a refund that + // failed. Other in-flight refunds are untouched. + if let Some(entry) = self.in_flight_refunds.get(&swap_address) { + if entry.refund_txid.is_none() { + self.in_flight_refunds.remove(&swap_address); + } + } Task::done(Message::View(view::Message::ShowError(format!( "Refund failed: {}", e From 7c1ba70f101c7d113a1aaa3ec08c1b37fdd7269a Mon Sep 17 00:00:00 2001 From: SatoshiSound Date: Tue, 14 Apr 2026 23:02:53 +0100 Subject: [PATCH 7/8] fix bugs --- coincube-gui/src/app/breez/client.rs | 11 ++- coincube-gui/src/app/breez/swap_status.rs | 2 +- coincube-gui/src/app/mod.rs | 47 +++++++++---- .../src/app/state/liquid/transactions.rs | 67 ++++++++++++++----- .../src/app/view/liquid/transactions.rs | 22 +----- coincube-gui/src/app/view/message.rs | 6 ++ coincube-gui/src/utils/mod.rs | 41 ++++++++++++ 7 files changed, 137 insertions(+), 59 deletions(-) diff --git a/coincube-gui/src/app/breez/client.rs b/coincube-gui/src/app/breez/client.rs index 7a0c45cd8..fc4cb28c5 100644 --- a/coincube-gui/src/app/breez/client.rs +++ b/coincube-gui/src/app/breez/client.rs @@ -767,13 +767,12 @@ fn make_breez_stream(state: &BreezSubscriptionState) -> impl Stream String { - if s.len() <= 14 { - return s.to_string(); - } - let head = &s[..6]; - let tail = &s[s.len() - 6..]; - format!("{head}…{tail}") + crate::utils::truncate_middle(s, 6, 6) } /// Converts `amount` base-units to an `f64` display value by dividing by `10^precision`. diff --git a/coincube-gui/src/app/breez/swap_status.rs b/coincube-gui/src/app/breez/swap_status.rs index 2a961282c..f168c3b23 100644 --- a/coincube-gui/src/app/breez/swap_status.rs +++ b/coincube-gui/src/app/breez/swap_status.rs @@ -60,7 +60,7 @@ impl BtcSwapReceiveStatus { Self::Refundable => "Refundable", Self::Refunding => "Refund broadcasting", Self::Refunded => "Refunded", - Self::Completed => "Received", + Self::Completed => "Completed", Self::Failed => "Failed", } } diff --git a/coincube-gui/src/app/mod.rs b/coincube-gui/src/app/mod.rs index 71c67c0ad..20934a896 100644 --- a/coincube-gui/src/app/mod.rs +++ b/coincube-gui/src/app/mod.rs @@ -52,6 +52,7 @@ use crate::{ bitcoind::{internal_bitcoind_datadir, internal_bitcoind_debug_log_path, Bitcoind}, NodeType, }, + utils::truncate_middle, }; use self::state::settings::SettingsState as GeneralSettingsState; @@ -1947,7 +1948,7 @@ impl App { log::info!( target: "breez_swap", "SdkEvent::PaymentRefundable tx_id={:?}", - details.tx_id + details.tx_id.as_deref().map(|t| truncate_middle(t, 6, 6)) ); let mut tasks = Vec::new(); if let Some(msg) = self.panels.active_liquid_refresh(true) { @@ -1960,7 +1961,7 @@ impl App { log::info!( target: "breez_swap", "SdkEvent::PaymentRefundPending tx_id={:?}", - details.tx_id + details.tx_id.as_deref().map(|t| truncate_middle(t, 6, 6)) ); let mut tasks = Vec::new(); if let Some(msg) = self.panels.active_liquid_refresh(true) { @@ -1973,7 +1974,7 @@ impl App { log::info!( target: "breez_swap", "SdkEvent::PaymentRefunded tx_id={:?}", - details.tx_id + details.tx_id.as_deref().map(|t| truncate_middle(t, 6, 6)) ); let mut tasks = vec![Task::done(Message::View(view::Message::Home( view::HomeMessage::RefreshLiquidBalance, @@ -2072,18 +2073,36 @@ impl App { // We intentionally *don't* touch these fields for a manual // reload response — see the `RefundablesLoaded` arm below. self.refundables_fetch_in_flight = false; - if result.is_ok() { - self.last_refundables_fetch = Some(std::time::Instant::now()); + match result { + Ok(refundables) => { + self.last_refundables_fetch = Some(std::time::Instant::now()); + // Forward the payload to LiquidTransactions through + // the panel's regular handler. The panel's + // reconciliation logic is origin-agnostic, so a poll + // result is converted to a `RefundablesLoaded` for + // it. + return self.panels.liquid_transactions.update( + self.daemon.clone(), + &self.cache, + Message::RefundablesLoaded(Ok(refundables)), + ); + } + Err(e) => { + // Swallow: this is a background debounce poll the + // user didn't initiate. Surfacing it as a global + // ShowError toast — which is what + // `RefundablesLoaded(Err)` in LiquidTransactions + // does — would interrupt whichever panel the user + // is currently viewing with an error they have no + // context for. Log locally and let the next poll + // (or a manual reload) retry. + log::warn!( + target: "breez_swap", + "background refundables poll failed: {}", + e + ); + } } - // Forward the payload to LiquidTransactions through the - // panel's regular handler. The panel's reconciliation logic - // is origin-agnostic, so a poll result is converted to a - // `RefundablesLoaded` for it. - return self.panels.liquid_transactions.update( - self.daemon.clone(), - &self.cache, - Message::RefundablesLoaded(result), - ); } msg @ Message::RefundablesLoaded(_) | msg @ Message::RefundCompleted { .. } => { return self.panels.liquid_transactions.update( diff --git a/coincube-gui/src/app/state/liquid/transactions.rs b/coincube-gui/src/app/state/liquid/transactions.rs index 0a47b8d3a..de864d658 100644 --- a/coincube-gui/src/app/state/liquid/transactions.rs +++ b/coincube-gui/src/app/state/liquid/transactions.rs @@ -63,6 +63,15 @@ pub struct LiquidTransactions { /// Refunds submitted by the user that have not yet been dropped from the /// SDK's refundables list. Keyed by swap_address. See `InFlightRefund`. in_flight_refunds: HashMap, + /// Monotonically-increasing token issued each time the user taps + /// "Use Vault address". The task that calls `daemon.get_new_address()` + /// captures the token and replies with `VaultRefundAddressResolved`; + /// if `pending_vault_refund_id` has since moved on (because the user + /// typed their own address or clicked the button again), the late + /// response is discarded. Without this, a slow daemon could overwrite + /// the user's freshly-typed input with a stale Vault address. + next_vault_refund_id: u64, + pending_vault_refund_id: Option, empty_state_quote: Quote, empty_state_image_handle: image::Handle, } @@ -94,19 +103,13 @@ impl LiquidTransactions { asset_filter: AssetFilter::All, pending_fee_priority: None, in_flight_refunds: HashMap::new(), + next_vault_refund_id: 0, + pending_vault_refund_id: None, empty_state_quote, empty_state_image_handle, } } - pub fn in_flight_refunds(&self) -> &HashMap { - &self.in_flight_refunds - } - - pub fn pending_fee_priority(&self) -> Option { - self.pending_fee_priority - } - fn reconcile_in_flight(&mut self, mut refundables: Vec) { let returned: std::collections::HashSet = refundables.iter().map(|r| r.swap_address.clone()).collect(); @@ -429,6 +432,14 @@ impl State for LiquidTransactions { Task::none() } Message::View(view::Message::RefundAddressEdited(address)) => { + // Any incoming edit — whether typed by the user or delivered + // from a matching `VaultRefundAddressResolved` — means the + // address input now holds a value we consider authoritative. + // Clearing the pending Vault request id here causes any + // still-in-flight `get_new_address()` call to be discarded + // when it eventually lands, so a slow daemon can't clobber + // what the user is actively typing. + self.pending_vault_refund_id = None; self.refund_address.value = address; let breez_client = self.breez_client.clone(); let addr = self.refund_address.value.clone(); @@ -543,24 +554,46 @@ impl State for LiquidTransactions { "Vault is unavailable — cannot generate a refund address.".to_string(), ))); }; + // Issue a fresh token so that late responses from earlier + // clicks — or responses that arrive after the user has + // started typing their own address — can be identified and + // dropped by `VaultRefundAddressResolved`. + self.next_vault_refund_id = self.next_vault_refund_id.wrapping_add(1); + let request_id = self.next_vault_refund_id; + self.pending_vault_refund_id = Some(request_id); Task::perform( async move { - let res: Result = daemon + daemon .get_new_address() .await .map(|res| res.address.to_string()) - .map_err(|e| e.to_string()); - res + .map_err(|e| e.to_string()) }, - |result| match result { - Ok(addr) => Message::View(view::Message::RefundAddressEdited(addr)), - Err(e) => Message::View(view::Message::ShowError(format!( - "Could not generate Vault refund address: {}", - e - ))), + move |result| { + Message::View(view::Message::VaultRefundAddressResolved( + request_id, result, + )) }, ) } + Message::View(view::Message::VaultRefundAddressResolved(request_id, result)) => { + // Stale response guard: ignore anything that isn't tagged + // with the id currently in `pending_vault_refund_id`. This + // covers both the "user typed their own address" case + // (handler cleared the pending id) and the "user clicked + // again" case (handler bumped the id). + if self.pending_vault_refund_id != Some(request_id) { + return Task::none(); + } + self.pending_vault_refund_id = None; + match result { + Ok(addr) => Task::done(Message::View(view::Message::RefundAddressEdited(addr))), + Err(e) => Task::done(Message::View(view::Message::ShowError(format!( + "Could not generate Vault refund address: {}", + e + )))), + } + } Message::View(view::Message::SubmitRefund) => { if let Some(refundable) = &self.selected_refundable { self.refunding = true; diff --git a/coincube-gui/src/app/view/liquid/transactions.rs b/coincube-gui/src/app/view/liquid/transactions.rs index 23b494d6d..73ebfe9a5 100644 --- a/coincube-gui/src/app/view/liquid/transactions.rs +++ b/coincube-gui/src/app/view/liquid/transactions.rs @@ -30,7 +30,7 @@ use crate::app::state::liquid::transactions::{AssetFilter, InFlightRefund}; use crate::app::view::message::{FeeratePriority, Message}; use crate::app::view::FiatAmountConverter; use crate::export::ImportExportMessage; -use crate::utils::{format_time_ago, format_timestamp}; +use crate::utils::{format_time_ago, format_timestamp, truncate_middle}; /// Styled status cell for the payment detail card. For BTC onchain swap /// payments this routes through `classify_payment`, which gives us the full @@ -71,15 +71,6 @@ fn payment_status_text( } } -/// Truncate a long on-chain address / txid like `bc1p7g…7ff6v` so it fits -/// inside a card without overflowing. Used for display only. -fn truncate_middle(s: &str, prefix_len: usize, suffix_len: usize) -> String { - if s.len() <= prefix_len + suffix_len + 3 { - return s.to_string(); - } - format!("{}…{}", &s[..prefix_len], &s[s.len() - suffix_len..]) -} - /// Returns `Some(formatted_usdt_string)` when the payment is a USDt asset payment. fn usdt_amount_str(payment: &Payment, usdt_id: &str) -> Option { if let PaymentDetails::Liquid { @@ -927,17 +918,6 @@ fn detail_back_button() -> Element<'static, Message> { mod tests { use super::*; - #[test] - fn truncate_middle_short_string_unchanged() { - assert_eq!(truncate_middle("short", 6, 6), "short"); - } - - #[test] - fn truncate_middle_elides_center() { - let long = "bc1p7gznc2zpn7aq3vqd695eml450d2ls33vw65tvwd77x936jquadnsp7ff6v"; - assert_eq!(truncate_middle(long, 6, 6), "bc1p7g…p7ff6v"); - } - #[test] fn refundables_gated_out_of_usdt_filter() { // Contract guard: refundables are BTC→L-BTC swap refunds, so they diff --git a/coincube-gui/src/app/view/message.rs b/coincube-gui/src/app/view/message.rs index c27ebbab4..a48e95a8c 100644 --- a/coincube-gui/src/app/view/message.rs +++ b/coincube-gui/src/app/view/message.rs @@ -83,6 +83,12 @@ pub enum Message { /// `daemon.get_new_address()` path so no address-derivation logic is /// duplicated here. GenerateVaultRefundAddress, + /// Result of the async `daemon.get_new_address()` call spawned by + /// `GenerateVaultRefundAddress`. Carries the request id so the handler + /// can ignore stale responses — e.g. when the user typed their own + /// address (clearing the pending id) before the Vault lookup returned, + /// or clicked the button twice. `Ok` = address, `Err` = error message. + VaultRefundAddressResolved(u64, Result), SelectPayment(OutPoint), Label(Vec, LabelMessage), NextReceiveAddress, diff --git a/coincube-gui/src/utils/mod.rs b/coincube-gui/src/utils/mod.rs index 55bc2ad7e..f9622f536 100644 --- a/coincube-gui/src/utils/mod.rs +++ b/coincube-gui/src/utils/mod.rs @@ -79,3 +79,44 @@ pub fn format_time_ago(timestamp: i64) -> String { format!("{} day{} ago", days, if days == 1 { "" } else { "s" }) } } + +/// Middle-elide an address or txid for display/logging: +/// `bc1p7g…p7ff6v`. Keeps the first `prefix_len` and last `suffix_len` +/// characters joined by a `…`. Strings short enough that elision would not +/// actually shorten them (i.e. `len <= prefix_len + suffix_len + 3`, where +/// 3 accounts for the ellipsis + sentinel overhead) are returned unchanged. +/// +/// This is the single shared implementation used by both UI rendering +/// (`view::liquid::transactions`) and privacy-preserving logging +/// (`app::breez::client`). Keeping one threshold here prevents a 15-char +/// string from being rendered differently in the two call sites. +pub fn truncate_middle(s: &str, prefix_len: usize, suffix_len: usize) -> String { + if s.len() <= prefix_len + suffix_len + 3 { + return s.to_string(); + } + format!("{}…{}", &s[..prefix_len], &s[s.len() - suffix_len..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_middle_short_unchanged() { + assert_eq!(truncate_middle("short", 6, 6), "short"); + } + + #[test] + fn truncate_middle_elides_center() { + let long = "bc1p7gznc2zpn7aq3vqd695eml450d2ls33vw65tvwd77x936jquadnsp7ff6v"; + assert_eq!(truncate_middle(long, 6, 6), "bc1p7g…p7ff6v"); + } + + #[test] + fn truncate_middle_boundary_15_chars_unchanged() { + // Regression guard: both prior impls disagreed on a 15-char input. + // The shared impl must treat it consistently — unchanged, because + // truncating to 6…6 = 13 chars is not a meaningful shortening. + assert_eq!(truncate_middle("123456789012345", 6, 6), "123456789012345"); + } +} From 8e4c72fede9a259fcf9ad266d2c64e64dd4820c5 Mon Sep 17 00:00:00 2001 From: SatoshiSound Date: Tue, 14 Apr 2026 23:48:18 +0100 Subject: [PATCH 8/8] cleanup --- coincube-gui/src/app/state/liquid/transactions.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/coincube-gui/src/app/state/liquid/transactions.rs b/coincube-gui/src/app/state/liquid/transactions.rs index de864d658..a9f9f2f92 100644 --- a/coincube-gui/src/app/state/liquid/transactions.rs +++ b/coincube-gui/src/app/state/liquid/transactions.rs @@ -341,6 +341,11 @@ impl State for LiquidTransactions { self.refund_address = form::Value::default(); self.refund_feerate = form::Value::default(); self.pending_fee_priority = None; + // Drop any in-flight Vault address lookup from a previous + // refundable. Without this, switching from Refundable A to + // Refundable B while A's `get_new_address()` is still in + // flight would silently drop A's address into B's form. + self.pending_vault_refund_id = None; Task::none() } Message::View(view::Message::Reload) => self.reload(None, None), @@ -351,6 +356,10 @@ impl State for LiquidTransactions { self.refund_address = form::Value::default(); self.refund_feerate = form::Value::default(); self.pending_fee_priority = None; + // Drop any in-flight Vault address lookup — the refundable + // detail screen is being torn down, so a late response must + // not reappear in a subsequently-opened refundable. + self.pending_vault_refund_id = None; Task::none() } Message::View(view::Message::PreselectPayment(payment)) => {