Skip to content

Commit 76df4f9

Browse files
committed
cleanup
1 parent b5549d9 commit 76df4f9

7 files changed

Lines changed: 188 additions & 85 deletions

File tree

coincube-gui/src/app/message.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ pub enum Message {
7777
Export(ImportExportMessage),
7878
PaymentsLoaded(Result<Vec<breez_sdk_liquid::prelude::Payment>, BreezError>),
7979
RefundablesLoaded(Result<Vec<breez_sdk_liquid::prelude::RefundableSwap>, BreezError>),
80+
/// Result of a debounced background poll started by
81+
/// `App::refresh_refundables_task`. Distinct from `RefundablesLoaded`
82+
/// (which is produced by manual panel reloads) so that only poll
83+
/// responses touch the App's debounce/in-flight tracking. A reload
84+
/// response racing ahead of a poll must not clear the in-flight flag,
85+
/// or a second concurrent `list_refundables()` could be launched.
86+
RefundablesPolled(Result<Vec<breez_sdk_liquid::prelude::RefundableSwap>, BreezError>),
8087
RefundCompleted(Result<breez_sdk_liquid::model::RefundResponse, BreezError>),
8188
BreezInfo(Result<breez_sdk_liquid::prelude::GetInfoResponse, BreezError>),
8289
BreezEvent(breez_sdk_liquid::prelude::SdkEvent),

coincube-gui/src/app/mod.rs

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,8 +1265,10 @@ impl App {
12651265

12661266
/// Kick off a background `list_refundables()` poll, debounced so that
12671267
/// SDK events (which can fire several times a second during sync) don't
1268-
/// hammer the SDK. Result is routed back to `Message::RefundablesLoaded`,
1269-
/// which `LiquidTransactions::update()` already handles.
1268+
/// hammer the SDK. Result comes back as `Message::RefundablesPolled` —
1269+
/// a variant distinct from `RefundablesLoaded` (which manual panel
1270+
/// reloads produce) so that only poll responses touch the App's
1271+
/// debounce and in-flight fields.
12701272
///
12711273
/// The Transactions panel itself fetches refundables on every reload()
12721274
/// too — this debounced helper covers the case where the user is sitting
@@ -1292,7 +1294,7 @@ impl App {
12921294
let client = self.breez_client.clone();
12931295
Task::perform(
12941296
async move { client.list_refundables().await },
1295-
Message::RefundablesLoaded,
1297+
Message::RefundablesPolled,
12961298
)
12971299
}
12981300

@@ -2064,16 +2066,27 @@ impl App {
20642066
// handlers above) land on the correct panel even when the user is
20652067
// sitting on a different screen. Otherwise the result would be
20662068
// dropped into whatever panel happens to be current.
2067-
msg @ Message::RefundablesLoaded(_) | msg @ Message::RefundCompleted(_) => {
2068-
if let Message::RefundablesLoaded(result) = &msg {
2069-
// Clear the in-flight guard regardless of outcome, but
2070-
// only advance the debounce timestamp on success so a
2071-
// failed poll doesn't suppress retries for 30s.
2072-
self.refundables_fetch_in_flight = false;
2073-
if result.is_ok() {
2074-
self.last_refundables_fetch = Some(std::time::Instant::now());
2075-
}
2069+
Message::RefundablesPolled(result) => {
2070+
// Poll response: clear the in-flight guard regardless of
2071+
// outcome, but only advance the debounce timestamp on
2072+
// success so a failed poll doesn't suppress retries for 30s.
2073+
// We intentionally *don't* touch these fields for a manual
2074+
// reload response — see the `RefundablesLoaded` arm below.
2075+
self.refundables_fetch_in_flight = false;
2076+
if result.is_ok() {
2077+
self.last_refundables_fetch = Some(std::time::Instant::now());
20762078
}
2079+
// Forward the payload to LiquidTransactions through the
2080+
// panel's regular handler. The panel's reconciliation logic
2081+
// is origin-agnostic, so a poll result is converted to a
2082+
// `RefundablesLoaded` for it.
2083+
return self.panels.liquid_transactions.update(
2084+
self.daemon.clone(),
2085+
&self.cache,
2086+
Message::RefundablesLoaded(result),
2087+
);
2088+
}
2089+
msg @ Message::RefundablesLoaded(_) | msg @ Message::RefundCompleted(_) => {
20772090
return self.panels.liquid_transactions.update(
20782091
self.daemon.clone(),
20792092
&self.cache,

coincube-gui/src/app/state/liquid/overview.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ impl State for LiquidOverview {
114114
fiat_converter,
115115
cache.bitcoin_unit,
116116
usdt_asset_id(self.breez_client.network()).unwrap_or(""),
117+
&[],
117118
),
118119
)
119120
} else {

coincube-gui/src/app/state/liquid/send.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,7 @@ impl State for LiquidSend {
397397
fiat_converter,
398398
cache.bitcoin_unit,
399399
usdt_asset_id(self.breez_client.network()).unwrap_or(""),
400+
&[],
400401
),
401402
)
402403
} else {

coincube-gui/src/app/state/liquid/transactions.rs

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,9 @@ impl LiquidTransactions {
107107
self.pending_fee_priority
108108
}
109109

110-
fn reconcile_in_flight(&mut self, refundables: Vec<RefundableSwap>) {
111-
let returned: std::collections::HashSet<&String> =
112-
refundables.iter().map(|r| &r.swap_address).collect();
110+
fn reconcile_in_flight(&mut self, mut refundables: Vec<RefundableSwap>) {
111+
let returned: std::collections::HashSet<String> =
112+
refundables.iter().map(|r| r.swap_address.clone()).collect();
113113
let now = Instant::now();
114114
self.in_flight_refunds.retain(|addr, entry| {
115115
if returned.contains(addr) {
@@ -123,6 +123,19 @@ impl LiquidTransactions {
123123
// banner would disappear before the user sees it.
124124
entry.refund_txid.is_none() && now.duration_since(entry.submitted_at) < IN_FLIGHT_GRACE
125125
});
126+
// Carry forward any locally-known RefundableSwap whose address still
127+
// has a grace-window in_flight entry but that the SDK dropped. The
128+
// view iterates `self.refundables` to render cards and only uses
129+
// `in_flight_refunds` for extra metadata, so without this the
130+
// "Refund broadcasting…" card would vanish the instant the SDK
131+
// stopped listing the swap, defeating the grace window.
132+
for prev in std::mem::take(&mut self.refundables) {
133+
if !returned.contains(&prev.swap_address)
134+
&& self.in_flight_refunds.contains_key(&prev.swap_address)
135+
{
136+
refundables.push(prev);
137+
}
138+
}
126139
self.refundables = refundables;
127140
}
128141

@@ -180,6 +193,11 @@ impl LiquidTransactions {
180193
impl State for LiquidTransactions {
181194
fn view<'a>(&'a self, menu: &'a Menu, cache: &'a Cache) -> Element<'a, view::Message> {
182195
let fiat_converter = cache.fiat_price.as_ref().and_then(|p| p.try_into().ok());
196+
let refundable_swap_addresses: Vec<String> = self
197+
.refundables
198+
.iter()
199+
.map(|r| r.swap_address.clone())
200+
.collect();
183201
let content = if let Some(payment) = &self.selected_payment {
184202
view::dashboard(
185203
menu,
@@ -189,6 +207,7 @@ impl State for LiquidTransactions {
189207
fiat_converter,
190208
cache.bitcoin_unit,
191209
usdt_asset_id(self.breez_client.network()).unwrap_or(""),
210+
&refundable_swap_addresses,
192211
),
193212
)
194213
} else if let Some(refundable) = &self.selected_refundable {
@@ -489,16 +508,30 @@ impl State for LiquidTransactions {
489508
}
490509
},
491510
move |rate: Option<usize>| {
492-
if let Some(rate) = rate {
493-
Message::View(view::Message::RefundFeerateEdited(rate.to_string()))
494-
} else {
495-
Message::View(view::Message::RefundFeeratePriorityFailed(
496-
"Failed to fetch fee rate".to_string(),
497-
))
498-
}
511+
// Tag the result with the priority that kicked off
512+
// the fetch. The handler in the update loop will
513+
// discard it if `pending_fee_priority` has moved on.
514+
Message::View(view::Message::RefundFeeratePriorityResolved(priority, rate))
499515
},
500516
)
501517
}
518+
Message::View(view::Message::RefundFeeratePriorityResolved(priority, rate)) => {
519+
// Ignore stale responses: if the user typed a custom feerate
520+
// (clearing `pending_fee_priority`) or clicked a different
521+
// priority button, this in-flight result must not clobber
522+
// their newer input.
523+
if self.pending_fee_priority != Some(priority) {
524+
return Task::none();
525+
}
526+
match rate {
527+
Some(rate) => Task::done(Message::View(view::Message::RefundFeerateEdited(
528+
rate.to_string(),
529+
))),
530+
None => Task::done(Message::View(view::Message::RefundFeeratePriorityFailed(
531+
"Failed to fetch fee rate".to_string(),
532+
))),
533+
}
534+
}
502535
Message::View(view::Message::GenerateVaultRefundAddress) => {
503536
// Reuse the Vault wallet's existing fresh-address derivation
504537
// (`daemon.get_new_address()`). This intentionally does NOT
@@ -579,13 +612,16 @@ impl State for LiquidTransactions {
579612
self.selected_refundable = None;
580613
self.refund_address = form::Value::default();
581614
self.refund_feerate = form::Value::default();
582-
Task::batch(vec![
583-
Task::done(Message::View(view::Message::ShowToast(
584-
log::Level::Info,
585-
format!("Refund broadcast · {}", txid.get(..10).unwrap_or(&txid)),
586-
))),
587-
Task::done(Message::View(view::Message::Close)),
588-
])
615+
// Do NOT emit view::Message::Close here: it routes globally
616+
// through App's panel router and would land on whatever
617+
// panel is currently active, resetting unrelated state if
618+
// the user navigated away while the refund was broadcasting.
619+
// The local field clears above already collapse this panel
620+
// back to the transactions list on the next render.
621+
Task::done(Message::View(view::Message::ShowToast(
622+
log::Level::Info,
623+
format!("Refund broadcast · {}", txid.get(..10).unwrap_or(&txid)),
624+
)))
589625
}
590626
Message::RefundCompleted(Err(e)) => {
591627
self.refunding = false;
@@ -685,4 +721,48 @@ mod tests {
685721
state.test_reconcile_in_flight(vec![sample_refundable("bc1q_active")]);
686722
assert!(state.in_flight_refunds.contains_key("bc1q_active"));
687723
}
724+
725+
#[test]
726+
fn in_flight_card_carried_forward_when_sdk_drops_optimistic_swap() {
727+
// Regression: grace window preserves the in_flight entry *and* the
728+
// RefundableSwap, so the view (which iterates self.refundables) keeps
729+
// rendering the "Refund broadcasting…" card until RefundCompleted.
730+
let mut state = new_state();
731+
state.refundables = vec![sample_refundable("bc1q_racing")];
732+
state.in_flight_refunds.insert(
733+
"bc1q_racing".to_string(),
734+
InFlightRefund {
735+
refund_txid: None,
736+
submitted_at: Instant::now(),
737+
},
738+
);
739+
740+
// SDK poll races ahead of RefundCompleted and no longer lists the swap.
741+
state.test_reconcile_in_flight(vec![]);
742+
743+
assert!(state.in_flight_refunds.contains_key("bc1q_racing"));
744+
assert_eq!(state.refundables.len(), 1);
745+
assert_eq!(state.refundables[0].swap_address, "bc1q_racing");
746+
}
747+
748+
#[test]
749+
fn in_flight_card_dropped_once_entry_removed() {
750+
// Carry-forward is tied to in_flight presence: once the entry is
751+
// dropped (e.g. txid set + absent from SDK list), the refundable
752+
// must also disappear.
753+
let mut state = new_state();
754+
state.refundables = vec![sample_refundable("bc1q_done")];
755+
state.in_flight_refunds.insert(
756+
"bc1q_done".to_string(),
757+
InFlightRefund {
758+
refund_txid: Some("deadbeef".to_string()),
759+
submitted_at: Instant::now(),
760+
},
761+
);
762+
763+
state.test_reconcile_in_flight(vec![]);
764+
765+
assert!(!state.in_flight_refunds.contains_key("bc1q_done"));
766+
assert!(state.refundables.is_empty());
767+
}
688768
}

0 commit comments

Comments
 (0)