Skip to content

Commit 1308d90

Browse files
Mostronatormostronator
andauthored
[NWC] Phase 3: Automatic hold invoice payment for sellers via NWC (#467)
* feat(nwc): Phase 3 — automatic invoice payment via NWC (#458) Add NWC auto-payment flow to PayLightningInvoiceScreen. When a wallet is connected via NWC, the user sees a 'Pay with Wallet' button instead of the manual QR code flow. New files: - NwcPaymentWidget: reusable payment UI with progress states (idle → paying → success/failed), balance check, error handling, and manual fallback - NWC_PHASE3_IMPLEMENTATION.md: detailed documentation Modified: - PayLightningInvoiceScreen: detects NWC connection, shows auto-pay or manual flow based on wallet state + user choice - NwcNotifier: added payInvoice() method with auto balance refresh - Localization: 11 new strings in EN, ES, IT for payment UI Features: - Balance-aware: disables button if wallet balance < invoice amount - Error handling: INSUFFICIENT_BALANCE, PAYMENT_FAILED, RATE_LIMITED, QUOTA_EXCEEDED, timeout — all with user-friendly messages - Retry: failed payments can be retried without leaving the screen - Fallback: 'Pay manually' link switches to QR code flow - No regression: manual flow unchanged when no NWC wallet connected Closes #458 * fix: safe preimage truncation and localized label - Add _truncatePreimage helper to prevent substring exceptions on short/null preimage values - Localize hardcoded 'Preimage:' label via nwcPreimageLabel key - Add nwcPreimageLabel to EN/ES/IT ARB files * docs: fix buyer/seller role swap in Phase 3 documentation The seller pays the hold invoice (escrow), not the buyer. The buyer provides an invoice to receive sats, not the seller. --------- Co-authored-by: mostronator <mostronator@users.noreply.github.com>
1 parent fe41511 commit 1308d90

File tree

7 files changed

+628
-26
lines changed

7 files changed

+628
-26
lines changed

docs/NWC_PHASE3_IMPLEMENTATION.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# NWC Phase 3: Automatic Hold Invoice Payment (Seller Flow)
2+
3+
## Overview
4+
5+
Phase 3 integrates NWC into the Mostro payment flow, enabling **automatic invoice payment** when a seller needs to pay the escrow invoice. When an NWC wallet is connected, the user sees a "Pay with Wallet" button instead of (or in addition to) the manual QR code flow.
6+
7+
## How It Works
8+
9+
### Flow with NWC Connected
10+
11+
1. Seller takes a buy order (or buyer takes a sell order and seller needs to fund escrow)
12+
2. Mostro daemon sends a `PaymentRequest` with an `lnInvoice`
13+
3. `PayLightningInvoiceScreen` detects NWC connection via `nwcProvider`
14+
4. User sees amount details + **"Pay with Wallet"** button with wallet balance
15+
5. On tap → `NwcPaymentWidget` calls `nwcNotifier.payInvoice(invoice)`
16+
6. Shows animated progress: sending → success ✅ or failure ❌
17+
7. On success → navigates home; Mostro updates order state via event stream
18+
8. On failure → shows error + "Retry" button + "Pay manually" fallback
19+
20+
### Flow without NWC (unchanged)
21+
22+
If no NWC wallet is connected, the original manual flow is shown: QR code + copy/share buttons. **No regression** — the NWC feature is purely additive.
23+
24+
### Fallback
25+
26+
If NWC payment fails, the user can tap "Pay manually instead" to switch to the QR code flow within the same screen. This ensures the user is never stuck.
27+
28+
## Architecture
29+
30+
### New Files
31+
32+
```text
33+
lib/shared/widgets/nwc_payment_widget.dart # Reusable NWC payment UI component
34+
docs/NWC_PHASE3_IMPLEMENTATION.md # This document
35+
```
36+
37+
### Modified Files
38+
39+
```text
40+
lib/features/order/screens/pay_lightning_invoice_screen.dart
41+
— Added NWC detection and conditional rendering
42+
— Shows NwcPaymentWidget when wallet connected, manual flow otherwise
43+
— Added _manualMode flag for fallback
44+
45+
lib/features/wallet/providers/nwc_provider.dart
46+
— Added payInvoice(invoice) method
47+
— Auto-refreshes balance after successful payment
48+
49+
lib/l10n/intl_en.arb, intl_es.arb, intl_it.arb
50+
— Added 11 NWC payment localization strings
51+
52+
lib/generated/l10n.dart, l10n_en.dart, l10n_es.dart, l10n_it.dart
53+
— Added generated getters for new localization strings
54+
```
55+
56+
## Key Components
57+
58+
### NwcPaymentWidget (`lib/shared/widgets/nwc_payment_widget.dart`)
59+
60+
A self-contained `ConsumerStatefulWidget` that handles the full NWC payment lifecycle:
61+
62+
**States:**
63+
- `idle` — Shows "Pay with Wallet" button with balance info
64+
- `paying` — Animated spinner with "Sending payment..." message
65+
- `success` — Green checkmark with preimage confirmation
66+
- `failed` — Error message + retry button + manual fallback link
67+
68+
**Features:**
69+
- Disables pay button if wallet balance < invoice amount
70+
- Shows wallet balance below the button for transparency
71+
- Handles all NWC error codes with user-friendly messages:
72+
- `INSUFFICIENT_BALANCE` → "Insufficient wallet balance"
73+
- `PAYMENT_FAILED` → "Payment failed. Please try again."
74+
- `RATE_LIMITED` → "Wallet is rate limited. Please wait a moment."
75+
- `QUOTA_EXCEEDED` → "Wallet spending quota exceeded"
76+
- Timeout → "Payment timed out. Please try again or pay manually."
77+
- Retry button resets to idle state for another attempt
78+
- "Pay manually instead" button triggers fallback callback
79+
80+
**Reusability:** Designed as a shared widget so it can be reused in Phase 4 (buyer flow — auto invoice generation via `make_invoice`) or any other screen that needs to pay a Lightning invoice.
81+
82+
### NwcNotifier.payInvoice()
83+
84+
New method on the NWC provider:
85+
86+
```dart
87+
Future<PayInvoiceResult> payInvoice(String invoice) async {
88+
// Validates connection, calls client.payInvoice(), refreshes balance
89+
}
90+
```
91+
92+
- Throws typed exceptions (`NwcResponseException`, `NwcTimeoutException`)
93+
- Auto-refreshes wallet balance after successful payment
94+
- Balance refresh failure is logged but doesn't break the payment result
95+
96+
### PayLightningInvoiceScreen Changes
97+
98+
The screen now:
99+
1. Watches `nwcProvider` state
100+
2. If connected + not in manual mode → shows `NwcPaymentWidget`
101+
3. If disconnected or manual mode → shows original `PayLightningInvoiceWidget`
102+
4. Cancel button always available in both modes
103+
104+
## Localization
105+
106+
Added 11 new strings in EN, ES, IT:
107+
108+
| Key | EN | ES | IT |
109+
|-----|----|----|-----|
110+
| payWithWallet | Pay with Wallet | Pagar con Billetera | Paga con Portafoglio |
111+
| nwcPaymentSending | Sending payment... | Enviando pago... | Invio pagamento... |
112+
| nwcPaymentSuccess | Payment Successful! | ¡Pago Exitoso! | Pagamento Riuscito! |
113+
| nwcPaymentFailed | Payment failed... | El pago falló... | Pagamento fallito... |
114+
| nwcPaymentTimeout | Payment timed out... | El pago expiró... | Pagamento scaduto... |
115+
| nwcInsufficientBalance | Insufficient wallet balance | Saldo insuficiente... | Saldo insufficiente... |
116+
| nwcRateLimited | Wallet is rate limited... | La billetera está limitada... | Il portafoglio è limitato... |
117+
| nwcQuotaExceeded | Wallet spending quota exceeded | Cuota de gastos excedida | Quota di spesa superata |
118+
| nwcRetryPayment | Retry Payment | Reintentar Pago | Riprova Pagamento |
119+
| nwcPayManually | Pay manually instead | Pagar manualmente | Paga manualmente |
120+
121+
## Edge Cases Handled
122+
123+
1. **NWC disconnects mid-payment** → Timeout after 30s → error state → retry/manual fallback
124+
2. **Invoice expires** → Wallet returns `PAYMENT_FAILED` → error shown with retry option
125+
3. **Insufficient balance** → Button disabled + balance shown in red + message
126+
4. **Balance unknown** → Button enabled (balance might be enough), let wallet decide
127+
5. **Multiple screens** → Each `NwcPaymentWidget` is independent with its own state
128+
129+
## What's Next (Phase 4+)
130+
131+
- **Phase 4**: Auto invoice generation (buyer receives sats via NWC `make_invoice`)
132+
- **Phase 5**: Payment notifications (kind 23197 events)
133+
134+
## References
135+
136+
- [Phase 1: Core Library](NWC_PHASE1_IMPLEMENTATION.md)
137+
- [Phase 2: Wallet Management UI](NWC_PHASE2_IMPLEMENTATION.md)
138+
- [NIP-47: pay_invoice](https://github.com/nostr-protocol/nips/blob/master/47.md#pay_invoice)
139+
- [Issue #458](https://github.com/MostroP2P/mobile/issues/458)

lib/features/order/screens/pay_lightning_invoice_screen.dart

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart';
44
import 'package:mostro_mobile/core/app_theme.dart';
55
import 'package:mostro_mobile/features/order/providers/order_notifier_provider.dart';
66
import 'package:mostro_mobile/features/order/widgets/order_app_bar.dart';
7+
import 'package:mostro_mobile/features/wallet/providers/nwc_provider.dart';
78
import 'package:mostro_mobile/shared/widgets/custom_card.dart';
9+
import 'package:mostro_mobile/shared/widgets/nwc_payment_widget.dart';
810
import 'package:mostro_mobile/shared/widgets/pay_lightning_invoice_widget.dart';
911
import 'package:mostro_mobile/generated/l10n.dart';
1012

@@ -20,6 +22,9 @@ class PayLightningInvoiceScreen extends ConsumerStatefulWidget {
2022

2123
class _PayLightningInvoiceScreenState
2224
extends ConsumerState<PayLightningInvoiceScreen> {
25+
/// Whether the user chose to pay manually (fallback from NWC).
26+
bool _manualMode = false;
27+
2328
@override
2429
Widget build(BuildContext context) {
2530
final orderState = ref.watch(orderNotifierProvider(widget.orderId));
@@ -30,32 +35,83 @@ class _PayLightningInvoiceScreenState
3035
final orderNotifier =
3136
ref.watch(orderNotifierProvider(widget.orderId).notifier);
3237

38+
final nwcState = ref.watch(nwcProvider);
39+
final isNwcConnected = nwcState.status == NwcStatus.connected;
40+
final showNwcPayment = isNwcConnected && !_manualMode && lnInvoice.isNotEmpty;
41+
3342
return Scaffold(
34-
backgroundColor: AppTheme.dark1,
35-
appBar: OrderAppBar(title: S.of(context)!.payLightningInvoice),
36-
body: CustomCard(
37-
padding: const EdgeInsets.all(16),
38-
child: Material(
39-
color: AppTheme.dark2,
40-
child: Column(
41-
crossAxisAlignment: CrossAxisAlignment.stretch,
42-
children: [
43+
backgroundColor: AppTheme.dark1,
44+
appBar: OrderAppBar(title: S.of(context)!.payLightningInvoice),
45+
body: CustomCard(
46+
padding: const EdgeInsets.all(16),
47+
child: Material(
48+
color: AppTheme.dark2,
49+
child: Column(
50+
crossAxisAlignment: CrossAxisAlignment.stretch,
51+
children: [
52+
if (showNwcPayment) ...[
53+
// NWC auto-payment flow
54+
Text(
55+
S.of(context)!.payInvoiceToContinue(
56+
sats.toString(),
57+
fiatCode,
58+
fiatAmount,
59+
widget.orderId,
60+
),
61+
style: const TextStyle(color: AppTheme.cream1, fontSize: 18),
62+
textAlign: TextAlign.center,
63+
),
64+
const SizedBox(height: 24),
65+
NwcPaymentWidget(
66+
lnInvoice: lnInvoice,
67+
sats: sats,
68+
onPaymentSuccess: () {
69+
// Payment succeeded — Mostro will update the order state
70+
// automatically via the event stream. We just navigate home.
71+
context.go('/');
72+
},
73+
onFallbackToManual: () {
74+
setState(() => _manualMode = true);
75+
},
76+
),
77+
const SizedBox(height: 20),
78+
Row(
79+
mainAxisAlignment: MainAxisAlignment.center,
80+
children: [
81+
ElevatedButton(
82+
onPressed: () async {
83+
context.go('/');
84+
await orderNotifier.cancelOrder();
85+
},
86+
style: ElevatedButton.styleFrom(
87+
foregroundColor: Colors.white,
88+
backgroundColor: Colors.red,
89+
),
90+
child: Text(S.of(context)!.cancel),
91+
),
92+
],
93+
),
94+
] else ...[
95+
// Manual payment flow (original)
4396
PayLightningInvoiceWidget(
44-
onSubmit: () async {
45-
context.go('/');
46-
},
47-
onCancel: () async {
48-
context.go('/');
49-
await orderNotifier.cancelOrder();
50-
},
51-
lnInvoice: lnInvoice,
52-
sats: sats,
53-
fiatAmount: fiatAmount,
54-
fiatCode: fiatCode,
55-
orderId: widget.orderId),
97+
onSubmit: () async {
98+
context.go('/');
99+
},
100+
onCancel: () async {
101+
context.go('/');
102+
await orderNotifier.cancelOrder();
103+
},
104+
lnInvoice: lnInvoice,
105+
sats: sats,
106+
fiatAmount: fiatAmount,
107+
fiatCode: fiatCode,
108+
orderId: widget.orderId,
109+
),
56110
],
57-
),
111+
],
58112
),
59-
));
113+
),
114+
),
115+
);
60116
}
61117
}

lib/features/wallet/providers/nwc_provider.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,31 @@ class NwcNotifier extends StateNotifier<NwcState> {
162162
logger.i('NWC: Disconnected and cleared stored connection');
163163
}
164164

165+
/// Pays a Lightning invoice via the connected NWC wallet.
166+
///
167+
/// Throws [NwcNotConnectedException] if no wallet is connected.
168+
/// Throws [NwcResponseException] if the wallet returns an error.
169+
/// Throws [NwcTimeoutException] if the payment times out.
170+
Future<PayInvoiceResult> payInvoice(String invoice) async {
171+
if (_client == null || !_client!.isConnected) {
172+
throw const NwcNotConnectedException('No wallet connected');
173+
}
174+
175+
final result = await _client!.payInvoice(
176+
PayInvoiceParams(invoice: invoice),
177+
);
178+
179+
// Refresh balance after successful payment
180+
try {
181+
final balance = await _client!.getBalance();
182+
state = state.copyWith(balanceMsats: balance.balance);
183+
} catch (e) {
184+
logger.w('NWC: Failed to refresh balance after payment: $e');
185+
}
186+
187+
return result;
188+
}
189+
165190
/// Refreshes the wallet balance.
166191
Future<void> refreshBalance() async {
167192
if (_client == null || !_client!.isConnected) return;

lib/l10n/intl_en.arb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1427,6 +1427,18 @@
14271427
"invalidNwcUri": "Invalid NWC URI",
14281428
"walletDisconnectConfirm": "Are you sure you want to disconnect your wallet?",
14291429
"connectWalletDescription": "Connect your Lightning wallet using a Nostr Wallet Connect (NWC) URI to view your balance and manage payments.",
1430-
"refreshBalance": "Refresh balance"
1430+
"refreshBalance": "Refresh balance",
1431+
1432+
"payWithWallet": "Pay with Wallet",
1433+
"nwcPaymentSending": "Sending payment...",
1434+
"nwcPaymentSuccess": "Payment Successful!",
1435+
"nwcPaymentFailed": "Payment failed. Please try again.",
1436+
"nwcPaymentTimeout": "Payment timed out. Please try again or pay manually.",
1437+
"nwcInsufficientBalance": "Insufficient wallet balance",
1438+
"nwcRateLimited": "Wallet is rate limited. Please wait a moment.",
1439+
"nwcQuotaExceeded": "Wallet spending quota exceeded",
1440+
"nwcRetryPayment": "Retry Payment",
1441+
"nwcPayManually": "Pay manually instead",
1442+
"nwcPreimageLabel": "Preimage"
14311443

14321444
}

lib/l10n/intl_es.arb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1403,6 +1403,18 @@
14031403
"invalidNwcUri": "URI NWC inválida",
14041404
"walletDisconnectConfirm": "¿Estás seguro de que quieres desconectar tu billetera?",
14051405
"connectWalletDescription": "Conecta tu billetera Lightning usando una URI de Nostr Wallet Connect (NWC) para ver tu saldo y gestionar pagos.",
1406-
"refreshBalance": "Actualizar saldo"
1406+
"refreshBalance": "Actualizar saldo",
1407+
1408+
"payWithWallet": "Pagar con Billetera",
1409+
"nwcPaymentSending": "Enviando pago...",
1410+
"nwcPaymentSuccess": "¡Pago Exitoso!",
1411+
"nwcPaymentFailed": "El pago falló. Por favor intenta de nuevo.",
1412+
"nwcPaymentTimeout": "El pago expiró. Intenta de nuevo o paga manualmente.",
1413+
"nwcInsufficientBalance": "Saldo insuficiente en la billetera",
1414+
"nwcRateLimited": "La billetera está limitada. Espera un momento.",
1415+
"nwcQuotaExceeded": "Cuota de gastos de la billetera excedida",
1416+
"nwcRetryPayment": "Reintentar Pago",
1417+
"nwcPayManually": "Pagar manualmente",
1418+
"nwcPreimageLabel": "Preimagen"
14071419

14081420
}

lib/l10n/intl_it.arb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1458,5 +1458,17 @@
14581458
"invalidNwcUri": "URI NWC non valida",
14591459
"walletDisconnectConfirm": "Sei sicuro di voler scollegare il tuo portafoglio?",
14601460
"connectWalletDescription": "Collega il tuo portafoglio Lightning usando un URI Nostr Wallet Connect (NWC) per visualizzare il saldo e gestire i pagamenti.",
1461-
"refreshBalance": "Aggiorna saldo"
1461+
"refreshBalance": "Aggiorna saldo",
1462+
1463+
"payWithWallet": "Paga con Portafoglio",
1464+
"nwcPaymentSending": "Invio pagamento...",
1465+
"nwcPaymentSuccess": "Pagamento Riuscito!",
1466+
"nwcPaymentFailed": "Pagamento fallito. Riprova.",
1467+
"nwcPaymentTimeout": "Pagamento scaduto. Riprova o paga manualmente.",
1468+
"nwcInsufficientBalance": "Saldo del portafoglio insufficiente",
1469+
"nwcRateLimited": "Il portafoglio è limitato. Attendi un momento.",
1470+
"nwcQuotaExceeded": "Quota di spesa del portafoglio superata",
1471+
"nwcRetryPayment": "Riprova Pagamento",
1472+
"nwcPayManually": "Paga manualmente",
1473+
"nwcPreimageLabel": "Preimage"
14621474
}

0 commit comments

Comments
 (0)