From b399c8665e029b79bbdf309602909b24de7db4d9 Mon Sep 17 00:00:00 2001 From: "@Delagado74" Date: Tue, 12 May 2026 11:34:18 -0400 Subject: [PATCH 1/3] fix: clear invoice now removes from history instead of leaving pending transactions When a user generates a Bolt11 invoice from the receive screen and presses 'Clear invoice', the invoice remained visible in the transaction history because _clearInvoice() only cleared local UI state without cancelling the pending invoice on the LNBits server. Add cancelInvoice() method to InvoiceService that attempts DELETE on LNBits API endpoints. If the server does not support cancellation, the payment hash is stored in a shared set and filtered from the history screen locally. --- lib/screens/7history_screen.dart | 13 +++++++--- lib/screens/9receive_screen.dart | 32 +++++++++++++++++++++++ lib/services/invoice_service.dart | 43 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/lib/screens/7history_screen.dart b/lib/screens/7history_screen.dart index ef05df8..54fb5df 100644 --- a/lib/screens/7history_screen.dart +++ b/lib/screens/7history_screen.dart @@ -10,6 +10,7 @@ import '../services/wallet_service.dart'; import '../models/transaction_info.dart'; import '../l10n/generated/app_localizations.dart'; import '../theme/app_tokens.dart'; +import '9receive_screen.dart'; class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @@ -165,14 +166,20 @@ class _HistoryScreenState extends State with TickerProviderStateM } List get _filteredTransactions { + var filtered = _transactions.where((tx) => + !(tx.isPending && + tx.paymentHash != null && + clearedInvoiceHashes.contains(tx.paymentHash)) + ).toList(); + switch (_currentFilter) { case TransactionFilter.incoming: - return _transactions.where((tx) => tx.isIncoming).toList(); + return filtered.where((tx) => tx.isIncoming).toList(); case TransactionFilter.outgoing: - return _transactions.where((tx) => tx.isOutgoing).toList(); + return filtered.where((tx) => tx.isOutgoing).toList(); case TransactionFilter.all: default: - return _transactions; + return filtered; } } diff --git a/lib/screens/9receive_screen.dart b/lib/screens/9receive_screen.dart index a7e2bf3..62a2f96 100644 --- a/lib/screens/9receive_screen.dart +++ b/lib/screens/9receive_screen.dart @@ -20,6 +20,8 @@ import '../theme/app_tokens.dart'; import '7ln_address_screen.dart'; import 'voucher_scan_screen.dart'; +final Set clearedInvoiceHashes = {}; + class ReceiveScreen extends StatefulWidget { const ReceiveScreen({super.key}); @@ -28,6 +30,7 @@ class ReceiveScreen extends StatefulWidget { } class _ReceiveScreenState extends State { + final _amountController = TextEditingController(); final _noteController = TextEditingController(); String _selectedCurrency = 'sats'; @@ -782,6 +785,12 @@ class _ReceiveScreenState extends State { void _clearInvoice() { _invoicePaymentTimer?.cancel(); _invoicePaymentTimeoutTimer?.cancel(); + + if (_generatedInvoice != null) { + final hash = _generatedInvoice!.paymentHash; + unawaited(_tryCancelInvoiceOnServer(hash)); + } + setState(() { _generatedInvoice = null; }); @@ -791,6 +800,29 @@ class _ReceiveScreenState extends State { ); } + Future _tryCancelInvoiceOnServer(String paymentHash) async { + try { + final walletProvider = context.read(); + final authProvider = context.read(); + final serverUrl = authProvider.sessionData?.serverUrl; + final wallet = walletProvider.primaryWallet; + + if (serverUrl == null || wallet == null) return; + + final cancelled = await _invoiceService.cancelInvoice( + serverUrl: serverUrl, + adminKey: wallet.inKey, + paymentHash: paymentHash, + ); + + if (!cancelled) { + clearedInvoiceHashes.add(paymentHash); + } + } catch (e) { + clearedInvoiceHashes.add(paymentHash); + } + } + void _showCopySheet(LNAddress? defaultAddress) { final hasInvoice = _generatedInvoice != null; final lnurl = defaultAddress?.lnurl; diff --git a/lib/services/invoice_service.dart b/lib/services/invoice_service.dart index 3ed677c..5baaa3c 100644 --- a/lib/services/invoice_service.dart +++ b/lib/services/invoice_service.dart @@ -1757,6 +1757,49 @@ class InvoiceService { }; } + Future cancelInvoice({ + required String serverUrl, + required String adminKey, + required String paymentHash, + }) async { + try { + String baseUrl = serverUrl; + if (!baseUrl.startsWith('http')) { + baseUrl = 'https://$baseUrl'; + } + + final headers = {'X-API-KEY': adminKey}; + + final endpoints = [ + '$baseUrl/api/v1/payments/$paymentHash', + '$baseUrl/api/v1/wallet/payment/$paymentHash', + ]; + + for (final endpoint in endpoints) { + try { + _debugLog('[INVOICE_SERVICE] Attempting to cancel invoice: $paymentHash'); + final response = await _dio.delete( + endpoint, + options: Options(headers: headers), + ); + if (response.statusCode == 200 || response.statusCode == 204) { + _debugLog('[INVOICE_SERVICE] Invoice cancelled successfully: $paymentHash'); + return true; + } + } catch (e) { + _debugLog('[INVOICE_SERVICE] Cancel failed at $endpoint: $e'); + continue; + } + } + + _debugLog('[INVOICE_SERVICE] No endpoint succeeded for invoice cancellation: $paymentHash'); + return false; + } catch (e) { + _debugLog('[INVOICE_SERVICE] Error cancelling invoice: $e'); + return false; + } + } + void dispose() { _dio.close(); } From 5646db67c4047fcb8d430cb379fb09ce97cd08f2 Mon Sep 17 00:00:00 2001 From: "@Delagado74" Date: Tue, 12 May 2026 13:41:05 -0400 Subject: [PATCH 2/3] fix: prevent cleared Bolt11 invoices from appearing in transaction history When a user generated a Bolt11 invoice on the receive screen and then pressed 'Clear invoice', or when the 10-minute monitoring timeout expired, or when navigating away, the invoice remained visible in the transaction history. This happened because the invoice was only removed from local UI state but was never cancelled on the LNBits server, and no local record of the cleared invoice was kept. Key changes: - Add cancelInvoice() to InvoiceService: attempts DELETE on LNBits API endpoints to cancel the pending invoice server-side - Add ClearedInvoiceStore: persists cleared invoice payment hashes to SharedPreferences as JSON, surviving app restarts and crashes - Add _discardInvoice() helper: used by both clear and timeout paths to ensure the hash is always recorded before async cancellation - Update HistoryScreen filter: excludes pending transactions whose payment hash is in the cleared set All discard paths (_clearInvoice, timeout, dispose) now record the hash synchronously before async cancellation is attempted, eliminating race conditions between clearing and history navigation. --- lib/main.dart | 4 +++ lib/screens/7history_screen.dart | 4 +-- lib/screens/9receive_screen.dart | 32 ++++++++++++-------- lib/services/cleared_invoice_store.dart | 40 +++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 lib/services/cleared_invoice_store.dart diff --git a/lib/main.dart b/lib/main.dart index 9aab4f8..c74131b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'services/wallet_service.dart'; import 'services/ln_address_service.dart'; import 'services/app_info_service.dart'; import 'services/deep_link_service.dart'; +import 'services/cleared_invoice_store.dart'; import 'screens/auth_checker.dart'; import 'screens/10send_screen.dart'; import 'l10n/generated/app_localizations.dart'; @@ -25,6 +26,9 @@ void main() async { // Initialize deep link service await DeepLinkService().initialize(); + // Load persisted cleared invoice hashes + await ClearedInvoiceStore.instance.load(); + runApp(const LaChispaApp()); } diff --git a/lib/screens/7history_screen.dart b/lib/screens/7history_screen.dart index 54fb5df..e1f130e 100644 --- a/lib/screens/7history_screen.dart +++ b/lib/screens/7history_screen.dart @@ -10,7 +10,7 @@ import '../services/wallet_service.dart'; import '../models/transaction_info.dart'; import '../l10n/generated/app_localizations.dart'; import '../theme/app_tokens.dart'; -import '9receive_screen.dart'; +import '../services/cleared_invoice_store.dart'; class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @@ -169,7 +169,7 @@ class _HistoryScreenState extends State with TickerProviderStateM var filtered = _transactions.where((tx) => !(tx.isPending && tx.paymentHash != null && - clearedInvoiceHashes.contains(tx.paymentHash)) + ClearedInvoiceStore.instance.contains(tx.paymentHash)) ).toList(); switch (_currentFilter) { diff --git a/lib/screens/9receive_screen.dart b/lib/screens/9receive_screen.dart index 62a2f96..a94b760 100644 --- a/lib/screens/9receive_screen.dart +++ b/lib/screens/9receive_screen.dart @@ -13,6 +13,7 @@ import '../services/invoice_service.dart'; import '../services/yadio_service.dart'; import '../services/transaction_detector.dart'; import '../services/nfc_charge_service.dart'; +import '../services/cleared_invoice_store.dart'; import '../models/lightning_invoice.dart'; import '../models/wallet_info.dart'; import '../l10n/generated/app_localizations.dart'; @@ -20,8 +21,6 @@ import '../theme/app_tokens.dart'; import '7ln_address_screen.dart'; import 'voucher_scan_screen.dart'; -final Set clearedInvoiceHashes = {}; - class ReceiveScreen extends StatefulWidget { const ReceiveScreen({super.key}); @@ -127,6 +126,9 @@ class _ReceiveScreenState extends State { _yadioService.dispose(); _invoicePaymentTimer?.cancel(); _invoicePaymentTimeoutTimer?.cancel(); + if (_generatedInvoice != null) { + ClearedInvoiceStore.instance.add(_generatedInvoice!.paymentHash); + } super.dispose(); } @@ -788,6 +790,7 @@ class _ReceiveScreenState extends State { if (_generatedInvoice != null) { final hash = _generatedInvoice!.paymentHash; + ClearedInvoiceStore.instance.add(hash); unawaited(_tryCancelInvoiceOnServer(hash)); } @@ -800,6 +803,17 @@ class _ReceiveScreenState extends State { ); } + void _discardInvoice() { + _invoicePaymentTimer?.cancel(); + _invoicePaymentTimeoutTimer?.cancel(); + if (_generatedInvoice != null) { + ClearedInvoiceStore.instance.add(_generatedInvoice!.paymentHash); + } + setState(() { + _generatedInvoice = null; + }); + } + Future _tryCancelInvoiceOnServer(String paymentHash) async { try { final walletProvider = context.read(); @@ -809,18 +823,12 @@ class _ReceiveScreenState extends State { if (serverUrl == null || wallet == null) return; - final cancelled = await _invoiceService.cancelInvoice( + await _invoiceService.cancelInvoice( serverUrl: serverUrl, adminKey: wallet.inKey, paymentHash: paymentHash, ); - - if (!cancelled) { - clearedInvoiceHashes.add(paymentHash); - } - } catch (e) { - clearedInvoiceHashes.add(paymentHash); - } + } catch (_) {} } void _showCopySheet(LNAddress? defaultAddress) { @@ -1468,9 +1476,7 @@ class _ReceiveScreenState extends State { _invoicePaymentTimeoutTimer = Timer(const Duration(minutes: 10), () { _invoicePaymentTimer?.cancel(); if (!mounted) return; - setState(() { - _generatedInvoice = null; - }); + _discardInvoice(); _showInfoSnackBar( AppLocalizations.of(context)!.invoice_monitoring_timeout_message, ); diff --git a/lib/services/cleared_invoice_store.dart b/lib/services/cleared_invoice_store.dart new file mode 100644 index 0000000..9c17339 --- /dev/null +++ b/lib/services/cleared_invoice_store.dart @@ -0,0 +1,40 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +class ClearedInvoiceStore { + static final ClearedInvoiceStore _instance = ClearedInvoiceStore._(); + static ClearedInvoiceStore get instance => _instance; + ClearedInvoiceStore._(); + + static const String _storageKey = 'cleared_invoice_hashes'; + Set _hashes = {}; + + Future load() async { + try { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(_storageKey); + if (stored != null && stored.isNotEmpty) { + final List decoded = jsonDecode(stored); + _hashes = decoded.cast().toSet(); + } + } catch (_) { + _hashes = {}; + } + } + + Future add(String hash) async { + _hashes.add(hash); + await _persist(); + } + + bool contains(String? hash) => hash != null && _hashes.contains(hash); + + Set get all => Set.unmodifiable(_hashes); + + Future _persist() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_storageKey, jsonEncode(_hashes.toList())); + } catch (_) {} + } +} From ca6589067cfbe625588cdd90aca839f70dc86224 Mon Sep 17 00:00:00 2001 From: "@Delagado74" Date: Tue, 12 May 2026 14:10:43 -0400 Subject: [PATCH 3/3] fix: also attempt server cancellation from discard and dispose paths CodeRabbit review found that _discardInvoice() and dispose() only recorded the cleared invoice hash locally but did not fire a best-effort server cancellation. This could leave pending invoices active on LNBits even when the user navigated away or the monitoring timeout fired. Add unawaited(_tryCancelInvoiceOnServer(hash)) to both paths so the DELETE attempt is always made regardless of how the invoice is discarded. --- lib/screens/9receive_screen.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/screens/9receive_screen.dart b/lib/screens/9receive_screen.dart index a94b760..4bf0b04 100644 --- a/lib/screens/9receive_screen.dart +++ b/lib/screens/9receive_screen.dart @@ -127,7 +127,9 @@ class _ReceiveScreenState extends State { _invoicePaymentTimer?.cancel(); _invoicePaymentTimeoutTimer?.cancel(); if (_generatedInvoice != null) { - ClearedInvoiceStore.instance.add(_generatedInvoice!.paymentHash); + final hash = _generatedInvoice!.paymentHash; + ClearedInvoiceStore.instance.add(hash); + unawaited(_tryCancelInvoiceOnServer(hash)); } super.dispose(); } @@ -807,7 +809,9 @@ class _ReceiveScreenState extends State { _invoicePaymentTimer?.cancel(); _invoicePaymentTimeoutTimer?.cancel(); if (_generatedInvoice != null) { - ClearedInvoiceStore.instance.add(_generatedInvoice!.paymentHash); + final hash = _generatedInvoice!.paymentHash; + ClearedInvoiceStore.instance.add(hash); + unawaited(_tryCancelInvoiceOnServer(hash)); } setState(() { _generatedInvoice = null;