@@ -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 {
180193impl 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