Skip to content

Bug: Cleared Bolt11 invoices from receive screen still appear in transaction history #107

@Delgado74

Description

@Delgado74

Description

When a user generates a Bolt11 invoice from the receive screen (ReceiveScreen) and then presses the "Clear invoice" button, the invoice should be completely removed with no trace. However, the invoice remains visible in the history screen (HistoryScreen), causing confusion.

Current behavior:

  1. User opens the receive screen and generates a Bolt11 invoice
  2. User decides not to use it and presses "Clear invoice"
  3. The invoice disappears from the receive screen
  4. When navigating to the history screen, the invoice appears as a pending transaction

Expected behavior:

  • When clearing an invoice, it should not appear in the transaction history

Root Cause

The issue is in lib/screens/9receive_screen.dart:782-792:

void _clearInvoice() {
    _invoicePaymentTimer?.cancel();
    _invoicePaymentTimeoutTimer?.cancel();
    setState(() {
      _generatedInvoice = null;
    });
    _showAccentSnackBar(
      icon: Icons.check_circle,
      message: AppLocalizations.of(context)!.invoice_cleared_message,
    );
}

_clearInvoice() only clears the widget's local state (_generatedInvoice = null) and cancels the monitoring timers. It does NOT make any API call to cancel/delete the invoice on the LNBits server.

Full Bug Flow

  1. Invoice creation (9receive_screen.dart:1296-1379): _confirmRequestAmount() calls _invoiceService.createInvoice() which does a POST /api/v1/payments with out: false to LNBits. This creates a pending transaction record on the server.

  2. Local cleanup (9receive_screen.dart:782-792): _clearInvoice() only cancels payment monitoring timers and resets _generatedInvoice to null. The invoice remains active on the LNBits server.

  3. History loading (7history_screen.dart:100-165, wallet_service.dart:301-419): _loadTransactions() calls walletService.getWalletTransactions() which does a GET /api/v1/payments. LNBits returns all transactions, including the pending invoices that the user "cleared".

  4. Response parsing (transaction_info.dart:86-181): TransactionInfo.fromJson() converts pending invoices ("pending": true) into TransactionInfo objects with status: TransactionStatus.pending, which are displayed in the history.


Possible Solutions

Option A (Recommended): Cancel the invoice on the LNBits server

Add a cancelInvoice() method in InvoiceService that attempts to cancel the pending invoice via the LNBits API, and call it from _clearInvoice().

Implementation:

In lib/services/invoice_service.dart, add:

Future<bool> cancelInvoice({
    required String serverUrl,
    required String adminKey,
    required String paymentHash,
}) async {
    String baseUrl = serverUrl;
    if (!baseUrl.startsWith('http')) {
        baseUrl = 'https://\';
    }

    final headers = {'X-API-KEY': adminKey};

    // Try multiple endpoints for compatibility across LNBits versions
    final endpoints = [
        '\/api/v1/payments/\',
        '\/api/v1/wallet/payment/\',
    ];

    for (final endpoint in endpoints) {
        try {
            final response = await _dio.delete(
                endpoint,
                options: Options(headers: headers),
            );
            if (response.statusCode == 200 || response.statusCode == 204) {
                return true;
            }
        } catch (e) {
            continue;
        }
    }
    return false;
}

Risks:

  • LNBits may not expose a DELETE endpoint for pending invoices in all versions/implementations
  • If the endpoint does not exist, cancellation fails silently

Option B: Local filtering with static Set

Maintain a static Set<String> of cleared invoice paymentHash values and filter them in the history screen.

Implementation:

In lib/screens/9receive_screen.dart:

static final Set<String> clearedInvoiceHashes = {};

void _clearInvoice() {
    _invoicePaymentTimer?.cancel();
    _invoicePaymentTimeoutTimer?.cancel();
    if (_generatedInvoice != null) {
        clearedInvoiceHashes.add(_generatedInvoice!.paymentHash);
    }
    setState(() {
      _generatedInvoice = null;
    });
    _showAccentSnackBar(...);
}

In lib/screens/7history_screen.dart:

List<TransactionInfo> get _filteredTransactions {
    var filtered = _transactions.where((tx) =>
        !(tx.isPending &&
          tx.paymentHash != null &&
          _ReceiveScreenState.clearedInvoiceHashes.contains(tx.paymentHash))
    ).toList();

    switch (_currentFilter) {
        case TransactionFilter.all:
        default:
            return filtered;
        case TransactionFilter.incoming:
            return filtered.where((tx) => tx.isIncoming).toList();
        case TransactionFilter.outgoing:
            return filtered.where((tx) => tx.isOutgoing).toList();
    }
}

Risks:

  • Only works during the current session (lost on app restart)
  • The invoice still exists on the server and could be paid unexpectedly
  • Does not scale well if many invoices are cleared

Option C: Hide all pending transactions from history

Filter out all pending status transactions from the history screen regardless of origin.

Implementation:

In lib/screens/7history_screen.dart, modify _filteredTransactions:

List<TransactionInfo> get _filteredTransactions {
    var base = _transactions.where((tx) => !tx.isPending).toList();

    switch (_currentFilter) {
        case TransactionFilter.all:
        default:
            return base;
        case TransactionFilter.incoming:
            return base.where((tx) => tx.isIncoming).toList();
        case TransactionFilter.outgoing:
            return base.where((tx) => tx.isOutgoing).toList();
    }
}

Risks:

  • Hides all pending transactions, not just cleared ones
  • An incoming payment in transit would not be visible until completed
  • May confuse users expecting to see pending transactions

Option D (Hybrid - Final Recommendation): Combine A + B

Attempt server-side cancellation (Option A) first. If it fails, add the hash to the local cleared set (Option B) as a fallback. This provides the best UX: if the server supports it, the invoice is truly gone; if not, it is at least filtered locally.

static final Set<String> clearedInvoiceHashes = {};

void _clearInvoice() async {
    _invoicePaymentTimer?.cancel();
    _invoicePaymentTimeoutTimer?.cancel();

    if (_generatedInvoice != null && mounted) {
        final hash = _generatedInvoice!.paymentHash;
        try {
            final walletProvider = context.read<WalletProvider>();
            final authProvider = context.read<AuthProvider>();
            final cancelled = await _invoiceService.cancelInvoice(
                serverUrl: authProvider.sessionData!.serverUrl,
                adminKey: walletProvider.primaryWallet!.inKey,
                paymentHash: hash,
            );
            if (!cancelled) {
                clearedInvoiceHashes.add(hash);
            }
        } catch (e) {
            clearedInvoiceHashes.add(hash);
        }
    }

    setState(() {
        _generatedInvoice = null;
    });
    _showAccentSnackBar(
      icon: Icons.check_circle,
      message: AppLocalizations.of(context)!.invoice_cleared_message,
    );
}

Relevant Files

File Lines Role
lib/screens/9receive_screen.dart 782-792 _clearInvoice() — only clears local state
lib/screens/9receive_screen.dart 1296-1379 _confirmRequestAmount() — creates invoice on LNBits
lib/services/invoice_service.dart 98-430 createInvoice() — POST to LNBits
lib/services/wallet_service.dart 301-419 getWalletTransactions() — GET history from server
lib/screens/7history_screen.dart 100-165 _loadTransactions() — loads history
lib/models/transaction_info.dart 86-181 fromJson() — parses pending invoices as valid transactions

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions