Skip to content

Commit 0da34dc

Browse files
Mostronatormostronator
andauthored
[NWC] Phase 5: Payment notifications and enhanced UX (#472)
* feat(nwc): Phase 5 — payment notifications and enhanced UX - Subscribe to NWC notification events (kind 23196) for real-time payment_received and payment_sent updates - Add NwcNotificationListener at app level for in-app snackbar notifications on payment events - Add pre-flight balance check before payment attempts - Add payment receipt widget with amount breakdown, fees, preimage, and timestamp - Add connection health monitoring with periodic checks (30s) - Add auto-reconnect with exponential backoff on connection drops - Add periodic balance refresh (60s) - Add lookup_invoice verification after successful payments - Add connection health indicator (green/orange) in wallet settings - Add NwcConnectionStatusIndicator reusable widget - Add low balance and connection unstable warnings in payment flow - Add 15 new localization strings (EN, ES, IT) - Add NWC_PHASE5_IMPLEMENTATION.md documentation Closes #460 * fix: address CodeRabbit review comments - Add 'since: DateTime.now()' filter to notification subscription to prevent replaying historical events on connect - Add reentrancy guard (_isReconnecting) to _handleConnectionDrop and convert to internal retry loop to prevent orphaned timers and double-incremented reconnect attempts - Re-subscribe to notification stream when NWC provider reconnects to prevent stale subscription after disconnect/reconnect cycle - Add mounted guard in _verifyPayment() to prevent accessing ref after widget disposal - Fix documentation: correct localization string count (14 new, not 15) and note that nwcConnectionError, nwcPaymentSuccess, nwcPreimageLabel were added in earlier phases --------- Co-authored-by: mostronator <mostronator@users.noreply.github.com>
1 parent d749733 commit 0da34dc

File tree

12 files changed

+5589
-4444
lines changed

12 files changed

+5589
-4444
lines changed

docs/NWC_PHASE5_IMPLEMENTATION.md

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
# NWC Phase 5: Payment Notifications and Enhanced UX
2+
3+
## Overview
4+
5+
Phase 5 is the final phase of NWC integration in Mostro Mobile. It adds **real-time payment notifications** via NIP-47 kind 23196 events, **enhanced payment UX** with pre-flight balance checks and payment receipts, **connection resilience** with auto-reconnect, and **wallet health monitoring** throughout the app.
6+
7+
## Architecture
8+
9+
### Modified Files
10+
11+
```text
12+
lib/services/nwc/nwc_client.dart
13+
— Added NwcNotification model
14+
— Added notification stream (kind 23196 subscription)
15+
— Added _subscribeToNotifications() for real-time wallet events
16+
17+
lib/features/wallet/providers/nwc_provider.dart
18+
— Added connection health tracking (connectionHealthy, lastSuccessfulContact)
19+
— Added auto-reconnect with exponential backoff on connection drops
20+
— Added periodic balance refresh (every 60s)
21+
— Added connection health checks (every 30s)
22+
— Added notification stream forwarding to UI
23+
— Added preFlightBalanceCheck() method
24+
— Added lookupInvoice() method for payment verification
25+
— Proper cleanup of timers and subscriptions
26+
27+
lib/shared/widgets/nwc_payment_widget.dart
28+
— Added pre-flight balance check step before payment
29+
— Added connection health warnings
30+
— Added low balance warnings
31+
— Replaced simple success indicator with full payment receipt
32+
— Added lookup_invoice verification after payment
33+
34+
lib/features/wallet/screens/wallet_settings_screen.dart
35+
— Connection health indicator (green/orange dot)
36+
37+
lib/core/app.dart
38+
— Integrated NwcNotificationListener at app level
39+
40+
lib/l10n/intl_en.arb, intl_es.arb, intl_it.arb
41+
— Added 14 new localization strings for Phase 5 features
42+
```
43+
44+
### New Files
45+
46+
```text
47+
lib/shared/widgets/nwc_connection_status_indicator.dart
48+
— Compact connection health indicator widget
49+
50+
lib/shared/widgets/nwc_payment_receipt_widget.dart
51+
— Payment receipt with amount breakdown, fees, preimage, timestamp
52+
53+
lib/shared/widgets/nwc_notification_listener.dart
54+
— App-level listener that shows snackbar notifications for payment events
55+
56+
docs/NWC_PHASE5_IMPLEMENTATION.md
57+
— This document
58+
```
59+
60+
## Key Components
61+
62+
### Real-time Notifications (NIP-47 kind 23196)
63+
64+
The NWC client now subscribes to kind 23196 events from the wallet after connecting. These are notification events defined in NIP-47 for real-time payment updates.
65+
66+
**NwcClient changes:**
67+
68+
```dart
69+
class NwcNotification {
70+
final String notificationType; // "payment_received", "payment_sent"
71+
final TransactionResult transaction;
72+
}
73+
74+
// Stream exposed on NwcClient
75+
Stream<NwcNotification> get notifications;
76+
```
77+
78+
On `connect()`, after encryption detection, the client calls `_subscribeToNotifications()` which:
79+
1. Creates a subscription for kind 23196 events from the wallet pubkey
80+
2. Verifies the `p` tag matches the client pubkey (events addressed to us)
81+
3. Decrypts the payload using the negotiated encryption mode
82+
4. Parses the `NwcResponse` and extracts the `TransactionResult`
83+
5. Emits the notification on the broadcast stream
84+
85+
**NwcNotifier** forwards these notifications to a UI-accessible stream and auto-refreshes the balance when `payment_received` or `payment_sent` events arrive.
86+
87+
**NwcNotificationListener** is integrated at the app level (`app.dart` builder) and shows floating snackbar notifications with:
88+
- Green arrow-down icon for received payments
89+
- Orange arrow-up icon for sent payments
90+
- Amount in sats
91+
92+
### Enhanced Payment UX
93+
94+
#### Pre-flight Balance Check
95+
96+
Before initiating a payment, `NwcPaymentWidget` now:
97+
1. Shows a "Checking balance..." state
98+
2. Calls `nwcNotifier.preFlightBalanceCheck(amountSats)`
99+
3. Refreshes the cached balance from the wallet
100+
4. If insufficient, shows error immediately (saves the user a failed payment attempt)
101+
5. If balance is unknown (check failed), proceeds anyway (let the wallet decide)
102+
103+
#### Payment Receipt
104+
105+
After successful payment, instead of a simple checkmark, the widget now shows `NwcPaymentReceiptWidget` with:
106+
- Amount paid in sats
107+
- Routing fees (if reported by the wallet, converted from msats)
108+
- Total (amount + fees)
109+
- Timestamp
110+
- Preimage with copy-to-clipboard button
111+
112+
#### lookup_invoice Verification
113+
114+
After a successful `pay_invoice`, the widget calls `lookupInvoice()` in the background to cross-reference the payment status on the wallet side. This provides extra reliability — if Mostro's confirmation is delayed, the wallet's own record confirms the payment went through. The result is logged but doesn't block the UI flow.
115+
116+
### Connection Resilience
117+
118+
#### Auto-reconnect
119+
120+
When the NWC connection drops (detected via health check timeout or notification stream error):
121+
1. The notifier saves the connection URI on initial connect
122+
2. On drop detection, it triggers reconnect with exponential backoff
123+
3. Delays: 2s, 4s, 8s, 16s, 32s (up to 5 attempts)
124+
4. Each reconnect attempt goes through the full `connect()` flow
125+
5. After 5 failed attempts, stops retrying (user can manually reconnect)
126+
127+
#### Periodic Health Checks
128+
129+
Every 30 seconds, the notifier performs a lightweight `get_balance` call to verify the connection is alive. If the call times out:
130+
- `connectionHealthy` is set to `false`
131+
- Auto-reconnect is triggered
132+
- UI shows orange indicators instead of green
133+
134+
#### Periodic Balance Refresh
135+
136+
Every 60 seconds, the notifier refreshes the wallet balance. This ensures the displayed balance stays reasonably current even without explicit user action. The refresh also serves as a soft health check.
137+
138+
#### Connection Health Indicator
139+
140+
`NwcConnectionStatusIndicator` is a compact widget showing:
141+
- Green wallet icon + "Connected" when healthy
142+
- Orange wifi-off icon + "Connection unstable" when unhealthy
143+
- Orange refresh icon + "Reconnecting..." during reconnect
144+
- Red alert icon + "Connection error" on error
145+
- Hidden when no wallet is configured
146+
147+
The wallet settings screen also shows green/orange dot based on health.
148+
149+
#### Graceful Degradation
150+
151+
`NwcPaymentWidget` shows a connection health warning banner when the connection is unstable but still connected. The "Pay manually instead" fallback remains available at all times after a failed payment, ensuring the user is never stuck if NWC becomes unreachable mid-trade.
152+
153+
### Wallet Balance in App
154+
155+
Balance is refreshed:
156+
1. On initial connect (via `get_balance`)
157+
2. Every 60 seconds via periodic timer
158+
3. After every `pay_invoice` and `make_invoice`
159+
4. When `payment_received` or `payment_sent` notifications arrive
160+
5. On manual refresh via wallet settings
161+
162+
The `NwcPaymentWidget` shows the balance below the "Pay with Wallet" button with color coding:
163+
- Normal color when balance >= payment amount
164+
- Red when balance < payment amount (button disabled)
165+
166+
## Localization
167+
168+
Added 14 new strings in EN, ES, IT:
169+
170+
| Key | EN |
171+
|-----|----|
172+
| nwcConnectionUnstable | Connection unstable |
173+
| nwcReconnecting | Reconnecting... |
174+
| nwcBalanceTooLow | Wallet balance is lower than the payment amount |
175+
| nwcCheckingBalance | Checking balance... |
176+
| nwcReceiptAmount | Amount |
177+
| nwcReceiptFees | Routing fees |
178+
| nwcReceiptTotal | Total |
179+
| nwcReceiptTimestamp | Time |
180+
| nwcReceiptDone | Done |
181+
| nwcPreimageCopied | Preimage copied to clipboard |
182+
| nwcPaymentReceived | Payment received! |
183+
| nwcPaymentSent | Payment sent! |
184+
| nwcNotificationPaymentReceived | Received {amount} sats |
185+
| nwcNotificationPaymentSent | Sent {amount} sats |
186+
187+
Note: `nwcConnectionError`, `nwcPaymentSuccess`, and `nwcPreimageLabel` were already added in Phases 2-3.
188+
189+
## State Changes
190+
191+
### NwcState
192+
193+
Two new fields added:
194+
195+
```dart
196+
class NwcState {
197+
// ... existing fields ...
198+
final bool connectionHealthy; // Whether relay communication is working
199+
final int? lastSuccessfulContact; // Timestamp of last successful wallet call
200+
}
201+
```
202+
203+
### NwcNotifier
204+
205+
New public methods:
206+
207+
```dart
208+
// Pre-flight balance check before payment
209+
Future<bool> preFlightBalanceCheck(int amountSats);
210+
211+
// Lookup an invoice for payment verification
212+
Future<TransactionResult?> lookupInvoice({String? paymentHash, String? invoice});
213+
214+
// Stream of payment notifications for UI consumption
215+
Stream<NwcNotification> get notifications;
216+
```
217+
218+
### NwcPaymentWidget
219+
220+
New payment status: `checking` (between `idle` and `paying`) for the pre-flight balance check step.
221+
222+
## Security
223+
224+
- Notification events are verified via `p` tag to ensure they're addressed to our client
225+
- Notification decryption uses the same negotiated encryption mode as requests
226+
- Auto-reconnect reuses the stored URI (already in secure storage)
227+
- No new sensitive data is stored or exposed
228+
229+
## References
230+
231+
- [Phase 1: Core Library](NWC_PHASE1_IMPLEMENTATION.md)
232+
- [Phase 2: Wallet Management UI](NWC_PHASE2_IMPLEMENTATION.md)
233+
- [Phase 3: Automatic Hold Invoice Payment](NWC_PHASE3_IMPLEMENTATION.md)
234+
- [Phase 4: Automatic Invoice Generation](NWC_PHASE4_IMPLEMENTATION.md)
235+
- [NIP-47: Notification Events](https://github.com/nostr-protocol/nips/blob/master/47.md#notification-events)
236+
- [Issue #460](https://github.com/MostroP2P/mobile/issues/460)

lib/core/app.dart

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'package:mostro_mobile/features/settings/settings_provider.dart';
1717
import 'package:mostro_mobile/shared/notifiers/locale_notifier.dart';
1818
import 'package:mostro_mobile/features/walkthrough/providers/first_run_provider.dart';
1919
import 'package:mostro_mobile/features/restore/restore_overlay.dart';
20+
import 'package:mostro_mobile/shared/widgets/nwc_notification_listener.dart';
2021

2122
class MostroApp extends ConsumerStatefulWidget {
2223
const MostroApp({super.key});
@@ -46,12 +47,12 @@ class _MostroAppState extends ConsumerState<MostroApp> {
4647
void _initializeDeepLinkInterceptor() {
4748
_deepLinkInterceptor = DeepLinkInterceptor();
4849
_deepLinkInterceptor!.initialize();
49-
50+
5051
// Listen for intercepted custom URLs
5152
_customUrlSubscription = _deepLinkInterceptor!.customUrlStream.listen(
5253
(url) async {
5354
debugPrint('Intercepted custom URL: $url');
54-
55+
5556
// Process the URL through our deep link handler
5657
if (_router != null) {
5758
try {
@@ -74,12 +75,12 @@ class _MostroAppState extends ConsumerState<MostroApp> {
7475
try {
7576
final appLinks = AppLinks();
7677
final initialUri = await appLinks.getInitialLink();
77-
78+
7879
if (initialUri != null && initialUri.scheme == 'mostro') {
7980
// Store the initial mostro URL for later processing
8081
// and prevent it from being passed to GoRouter
8182
debugPrint('Initial mostro deep link detected: $initialUri');
82-
83+
8384
// Schedule the deep link processing after the router is ready
8485
WidgetsBinding.instance.addPostFrameCallback((_) {
8586
_handleInitialMostroLink(initialUri);
@@ -95,7 +96,7 @@ class _MostroAppState extends ConsumerState<MostroApp> {
9596
try {
9697
// Wait for router to be ready
9798
await Future.delayed(const Duration(milliseconds: 100));
98-
99+
99100
if (_router != null) {
100101
final deepLinkHandler = ref.read(deepLinkHandlerProvider);
101102
await deepLinkHandler.handleInitialDeepLink(uri, _router!);
@@ -148,7 +149,7 @@ class _MostroAppState extends ConsumerState<MostroApp> {
148149
try {
149150
final deepLinkHandler = ref.read(deepLinkHandlerProvider);
150151
deepLinkHandler.initialize(_router!);
151-
152+
152153
_deepLinksInitialized = true;
153154
} catch (e, stackTrace) {
154155
// Log the error but don't set _deepLinksInitialized to true
@@ -165,11 +166,13 @@ class _MostroAppState extends ConsumerState<MostroApp> {
165166
darkTheme: AppTheme.theme,
166167
routerConfig: _router!,
167168
builder: (context, child) {
168-
return Stack(
169-
children: [
170-
if (child != null) child,
171-
const RestoreOverlay(),
172-
],
169+
return NwcNotificationListener(
170+
child: Stack(
171+
children: [
172+
if (child != null) child,
173+
const RestoreOverlay(),
174+
],
175+
),
173176
);
174177
},
175178
// Use language override from settings if available, otherwise let callback handle detection

0 commit comments

Comments
 (0)