diff --git a/rust/src/api/wallet.rs b/rust/src/api/wallet.rs index 9f397d2..61ff979 100644 --- a/rust/src/api/wallet.rs +++ b/rust/src/api/wallet.rs @@ -211,12 +211,21 @@ impl Wallet { return Ok(()); } - // Subscribe to quote state changes via WebSocket (NUT-17) with HTTP polling fallback - let mut subscription = self + // Try WebSocket (NUT-17) subscription — if unavailable, HTTP polling still works + let subscription = match self .inner .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote.id.clone()])) .await - .map_err(|e| Error::Cdk(e.to_string()))?; + { + Ok(sub) => Some(sub), + Err(e) => { + info!( + "Mint quote {}: WebSocket unavailable, using HTTP polling only: {e}", + quote.id + ); + None + } + }; let _self = self.clone(); flutter_rust_bridge::spawn(async move { @@ -234,114 +243,161 @@ impl Wallet { let expired_amount = quote.amount; let expired_expiry = quote.expiry; - let result = tokio::time::timeout(timeout_dur, async { - while let Some(event) = subscription.recv().await { - match event.into_inner() { - NotificationPayload::MintQuoteBolt11Response(info) - if info.state == CdkMintQuoteState::Paid => - { - info!("Mint quote {} paid via subscription", quote.id); + // Detect payment via two parallel paths: + // - Path A: WebSocket (NUT-17) — fast when it works (sats on most mints) + // - Path B: HTTP polling every 5s — fallback for mints that don't send + // WebSocket notifications for all units (e.g. Nutshell 0.20.0 + USD) + // First one to detect payment wins. + let quote_id_for_poll = quote.id.clone(); + let poll_wallet = _self.clone(); - // Notify Dart: payment detected - let _ = sink.add(MintQuote { - id: quote.id.clone(), - request: quote.request.clone(), - amount: quote.amount.map(|a| a.into()), - expiry: Some(quote.expiry), - state: CdkMintQuoteState::Paid.into(), - token: None, - error: None, - transaction_id: None, - }); + // Timeout only covers detection, not minting. + // If payment is detected near expiry, mint() must still complete. + enum Detected { + Paid, + Issued, + } - // Mint the ecash tokens - match _self - .inner - .mint("e.id, SplitTarget::None, None) - .await - { - Ok(mint_proofs) => { - let tx_id = match TransactionId::try_from( - mint_proofs.clone(), - ) { - Ok(id) => Some(id.to_string()), - Err(e) => { - info!("Failed to compute mint tx ID: {e}"); - None - } - }; - - let mint_amount = - mint_proofs.total_amount().unwrap_or_default(); - let _ = sink.add(MintQuote { - id: quote.id, - request: quote.request, - amount: Some(mint_amount.into()), - expiry: Some(quote.expiry), - state: CdkMintQuoteState::Issued.into(), - token: Token::try_from(CdkToken::new( - mint_url, - mint_proofs, - None, - unit, - )) - .ok(), - error: None, - transaction_id: tx_id, - }); - _self.update_balance_streams().await; + let mut subscription = subscription; + let detected = tokio::time::timeout(timeout_dur, async { + tokio::select! { + // Path A: WebSocket subscription (skipped if unavailable) + result = async { + let Some(ref mut sub) = subscription else { + return std::future::pending::().await; + }; + while let Some(event) = sub.recv().await { + match event.into_inner() { + NotificationPayload::MintQuoteBolt11Response(info) + if info.state == CdkMintQuoteState::Paid => + { + info!("Mint quote {} paid via WebSocket", quote.id); + return Detected::Paid; } - Err(e) => { - let _ = sink.add(MintQuote { - id: quote.id, - request: quote.request, - amount: quote.amount.map(|a| a.into()), - expiry: Some(quote.expiry), - state: MintQuoteState::Error, - token: None, - error: Some(e.to_string()), - transaction_id: None, - }); + NotificationPayload::MintQuoteBolt11Response(info) + if info.state == CdkMintQuoteState::Issued => + { + return Detected::Issued; } + _ => continue, } - return; } - NotificationPayload::MintQuoteBolt11Response(info) - if info.state == CdkMintQuoteState::Issued => - { - // Already issued (recovered from previous session) — notify Dart - // so it can clean up pending metadata and show success UI - let _ = sink.add(MintQuote { - id: quote.id.clone(), - request: quote.request.clone(), - amount: quote.amount.map(|a| a.into()), - expiry: Some(quote.expiry), - state: CdkMintQuoteState::Issued.into(), - token: None, - error: None, - transaction_id: None, - }); - _self.update_balance_streams().await; - return; + std::future::pending::().await + } => result, + + // Path B: HTTP polling fallback + result = async { + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + match poll_wallet.inner.check_mint_quote_status("e_id_for_poll).await { + Ok(q) if q.state == CdkMintQuoteState::Paid => { + info!("Mint quote {} paid via HTTP polling", quote_id_for_poll); + return Detected::Paid; + } + Ok(q) if q.state == CdkMintQuoteState::Issued => { + return Detected::Issued; + } + _ => continue, + } } - _ => continue, - } + } => result, } }) .await; - if result.is_err() { - // Timeout: quote expired - let _ = sink.add(MintQuote { - id: expired_id, - request: expired_request, - amount: expired_amount.map(|a| a.into()), - expiry: Some(expired_expiry), - state: MintQuoteState::Error, - token: None, - error: Some("Quote expired".to_string()), - transaction_id: None, - }); + // Handle detection result — mint logic runs outside the timeout + match detected { + Err(_) => { + // Timeout: quote expired + let _ = sink.add(MintQuote { + id: expired_id, + request: expired_request, + amount: expired_amount.map(|a| a.into()), + expiry: Some(expired_expiry), + state: MintQuoteState::Error, + token: None, + error: Some("Quote expired".to_string()), + transaction_id: None, + }); + } + Ok(Detected::Paid) => { + // Notify Dart: payment detected + let _ = sink.add(MintQuote { + id: quote.id.clone(), + request: quote.request.clone(), + amount: quote.amount.map(|a| a.into()), + expiry: Some(quote.expiry), + state: CdkMintQuoteState::Paid.into(), + token: None, + error: None, + transaction_id: None, + }); + + // Mint the ecash tokens (outside timeout) + match _self + .inner + .mint("e.id, SplitTarget::None, None) + .await + { + Ok(mint_proofs) => { + let tx_id = match TransactionId::try_from( + mint_proofs.clone(), + ) { + Ok(id) => Some(id.to_string()), + Err(e) => { + info!("Failed to compute mint tx ID: {e}"); + None + } + }; + + let mint_amount = + mint_proofs.total_amount().unwrap_or_default(); + let _ = sink.add(MintQuote { + id: quote.id, + request: quote.request, + amount: Some(mint_amount.into()), + expiry: Some(quote.expiry), + state: CdkMintQuoteState::Issued.into(), + token: Token::try_from(CdkToken::new( + mint_url, + mint_proofs, + None, + unit, + )) + .ok(), + error: None, + transaction_id: tx_id, + }); + _self.update_balance_streams().await; + } + Err(e) => { + let _ = sink.add(MintQuote { + id: quote.id, + request: quote.request, + amount: quote.amount.map(|a| a.into()), + expiry: Some(quote.expiry), + state: MintQuoteState::Error, + token: None, + error: Some(e.to_string()), + transaction_id: None, + }); + } + } + } + Ok(Detected::Issued) => { + // Already issued (recovered from previous session) + let _ = sink.add(MintQuote { + id: quote.id.clone(), + request: quote.request.clone(), + amount: quote.amount.map(|a| a.into()), + expiry: Some(quote.expiry), + state: CdkMintQuoteState::Issued.into(), + token: None, + error: None, + transaction_id: None, + }); + _self.update_balance_streams().await; + } } }); Ok(())