Skip to content

Commit f220dcf

Browse files
Mostronatormostronatorgrunch
authored
[NWC] Phase 4: Automatic invoice generation for buyers via NWC (#469)
* feat: add NWC Phase 4 - automatic invoice generation for buyers Implement automatic Lightning invoice generation via NWC make_invoice for buyers when their order is matched and no Lightning Address is set. Changes: - Add NwcInvoiceWidget: reusable widget for invoice generation flow with idle/generating/generated/failed states - Add NwcNotifier.makeInvoice(): converts sats to msats and calls NWC client's make_invoice method - Modify AddLightningInvoiceScreen: detect NWC connection and show auto-generation UI with fallback to manual input - Add 8 localization strings in EN, ES, IT for invoice generation UI - Add NWC_PHASE4_IMPLEMENTATION.md documentation Priority: Lightning Address > NWC make_invoice > Manual paste Closes #459 * fix: localize error message and use post-frame callbacks for SnackBars Address CodeRabbit review comment: - Replace hardcoded 'Failed to update invoice' with localized S.of(context)!.failedToUpdateInvoice(error) - Wrap SnackBar calls in addPostFrameCallback for safety - Add failedToUpdateInvoice key to EN/ES/IT ARB files * fix: handle empty invoice inline and disable confirm button when null Handle empty wallet invoice response inline instead of throwing into a generic catch block, preserving the specific error message. Disable the confirm button when invoice is null for clear visual feedback. * fix: use localized fallback for unhandled NWC error codes Instead of leaking raw e.message to the UI for unhandled error codes, log the technical details and show the generic localized error string. * fix: remove unused fiatAmount and fiatCode from NwcInvoiceWidget These constructor parameters were accepted and stored but never referenced in the widget's build methods or helpers. Removes dead code that would trigger analyzer warnings. --------- Co-authored-by: mostronator <mostronator@users.noreply.github.com> Co-authored-by: grunch <fjcalderon@gmail.com>
1 parent b62a6d4 commit f220dcf

File tree

7 files changed

+748
-47
lines changed

7 files changed

+748
-47
lines changed

docs/NWC_PHASE4_IMPLEMENTATION.md

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# NWC Phase 4: Automatic Invoice Generation for Buyers via NWC
2+
3+
## Overview
4+
5+
Phase 4 integrates NWC's `make_invoice` capability into the buyer flow. When a buyer's order is matched and Mostro requests a Lightning invoice, the app can now automatically generate one using the connected NWC wallet — eliminating the need for manual invoice creation and pasting.
6+
7+
## How It Works
8+
9+
### Priority Flow (Lightning Address → NWC → Manual)
10+
11+
```text
12+
Buyer's order is matched
13+
14+
15+
Has Lightning Address?
16+
┌────┴────┐
17+
Yes No
18+
│ │
19+
▼ ▼
20+
Mostro NWC connected?
21+
resolves ┌────┴────┐
22+
invoice Yes No
23+
(done!) │ │
24+
▼ ▼
25+
NWC Manual
26+
make_invoice paste
27+
```
28+
29+
1. **Lightning Address is set** → Mostro daemon resolves the invoice server-side. NWC is not needed. This is handled in `AbstractMostroNotifier` before the user ever reaches the invoice screen.
30+
2. **No Lightning Address, NWC connected**`AddLightningInvoiceScreen` detects the NWC connection and shows the "Generate with Wallet" button via `NwcInvoiceWidget`.
31+
3. **No Lightning Address, no NWC** → Original manual paste flow via `AddLightningInvoiceWidget`.
32+
33+
### Flow with NWC Connected
34+
35+
1. Buyer creates a buy order or takes a sell order
36+
2. Seller pays the escrow (hold invoice)
37+
3. Mostro asks the buyer for a Lightning invoice (and Lightning Address is not set)
38+
4. `AddLightningInvoiceScreen` detects NWC connection via `nwcProvider`
39+
5. User sees the order details + **"Generate with Wallet"** button
40+
6. On tap → `NwcInvoiceWidget` calls `nwcNotifier.makeInvoice(amount)`
41+
7. Shows animated progress: generating → generated ✅ or failed ❌
42+
8. On success → shows invoice preview + **"Confirm & Submit"** button
43+
9. On confirm → submits the invoice to Mostro and navigates home
44+
10. On failure → shows error + "Retry" button + "Enter manually instead" fallback
45+
46+
### Flow without NWC (unchanged)
47+
48+
If no NWC wallet is connected, the original manual flow is shown: text field where the user pastes an invoice. **No regression** — the NWC feature is purely additive.
49+
50+
### Fallback
51+
52+
If NWC invoice generation fails, the user can tap "Enter manually instead" to switch to the manual input flow within the same screen.
53+
54+
## Architecture
55+
56+
### New Files
57+
58+
```text
59+
lib/shared/widgets/nwc_invoice_widget.dart # Reusable NWC invoice generation UI
60+
docs/NWC_PHASE4_IMPLEMENTATION.md # This document
61+
```
62+
63+
### Modified Files
64+
65+
```text
66+
lib/features/order/screens/add_lightning_invoice_screen.dart
67+
— Added NWC detection and conditional rendering
68+
— Shows NwcInvoiceWidget when wallet connected, manual flow otherwise
69+
— Added _manualMode flag for fallback
70+
— Extracted _submitInvoice() and _cancelOrder() helper methods
71+
72+
lib/features/wallet/providers/nwc_provider.dart
73+
— Added makeInvoice(amountSats, {description, expiry}) method
74+
— Converts sats to msats for NWC protocol compliance
75+
— Auto-refreshes balance after invoice creation
76+
77+
lib/l10n/intl_en.arb, intl_es.arb, intl_it.arb
78+
— Added 8 NWC invoice generation localization strings
79+
80+
lib/generated/l10n.dart, l10n_en.dart, l10n_es.dart, l10n_it.dart
81+
— Added generated getters for new localization strings
82+
```
83+
84+
## Key Components
85+
86+
### NwcInvoiceWidget (`lib/shared/widgets/nwc_invoice_widget.dart`)
87+
88+
A self-contained `ConsumerStatefulWidget` that handles the full NWC invoice generation lifecycle:
89+
90+
**States:**
91+
- `idle` — Shows "Generate with Wallet" button with amount info
92+
- `generating` — Animated spinner with "Generating invoice..." message
93+
- `generated` — Green checkmark with invoice preview + "Confirm & Submit" button
94+
- `failed` — Error message + retry button + manual fallback link
95+
96+
**Features:**
97+
- Generates invoice with `Mostro order <orderId>` description for traceability
98+
- Shows truncated invoice preview (first 20 + last 20 chars) for user verification
99+
- Handles all NWC error codes with user-friendly messages
100+
- Retry button resets to idle state for another attempt
101+
- "Enter manually instead" button triggers fallback callback
102+
103+
**Design:** Follows the same pattern as `NwcPaymentWidget` (Phase 3) for consistency. Both are reusable shared widgets with similar state machines.
104+
105+
### NwcNotifier.makeInvoice()
106+
107+
New method on the NWC provider:
108+
109+
```dart
110+
Future<TransactionResult> makeInvoice(
111+
int amountSats, {
112+
String? description,
113+
int? expiry,
114+
}) async {
115+
// Validates connection, converts sats→msats, calls client.makeInvoice()
116+
// Refreshes balance after successful creation
117+
}
118+
```
119+
120+
- Takes amount in **satoshis** (converts to msats internally for NWC protocol)
121+
- Throws typed exceptions (`NwcResponseException`, `NwcTimeoutException`)
122+
- Auto-refreshes wallet balance after successful invoice creation
123+
- Balance refresh failure is logged but doesn't break the result
124+
125+
### AddLightningInvoiceScreen Changes
126+
127+
The screen now:
128+
1. Watches `nwcProvider` state
129+
2. If connected + not in manual mode + amount > 0 → shows `NwcInvoiceWidget`
130+
3. If disconnected or manual mode → shows original `AddLightningInvoiceWidget`
131+
4. Cancel button always available in both modes
132+
5. Shared `_submitInvoice()` method used by both NWC and manual flows
133+
134+
## Localization
135+
136+
Added 8 new strings in EN, ES, IT:
137+
138+
| Key | EN | ES | IT |
139+
|-----|----|----|-----|
140+
| nwcGenerateWithWallet | Generate with Wallet | Generar con Billetera | Genera con Portafoglio |
141+
| nwcInvoiceGenerating | Generating invoice... | Generando factura... | Generazione fattura... |
142+
| nwcInvoiceGenerated | Invoice Generated! | ¡Factura Generada! | Fattura Generata! |
143+
| nwcInvoiceFailed | Invoice generation failed... | La generación falló... | Generazione fallita... |
144+
| nwcInvoiceTimeout | Invoice generation timed out... | La generación expiró... | Generazione scaduta... |
145+
| nwcConfirmInvoice | Confirm & Submit | Confirmar y Enviar | Conferma e Invia |
146+
| nwcRetryInvoice | Retry | Reintentar | Riprova |
147+
| nwcEnterManually | Enter manually instead | Ingresar manualmente | Inserisci manualmente |
148+
149+
## Edge Cases Handled
150+
151+
1. **NWC disconnects during generation** → Timeout after 30s → error state → retry/manual fallback
152+
2. **Amount is 0 or unknown** → Falls back to manual flow (can't generate invoice without amount)
153+
3. **Wallet returns empty invoice** → Treated as error → retry/manual fallback
154+
4. **Lightning Address already set** → NWC flow never reached (handled upstream in `AbstractMostroNotifier`)
155+
5. **Payment failed status** → Manual input forced (handled upstream in `AbstractMostroNotifier`)
156+
6. **User wants manual control** → "Enter manually instead" always available after failed generation
157+
158+
## Relationship with Lightning Address
159+
160+
Lightning Address takes precedence over NWC for invoice generation because:
161+
- Zero interaction required (fully automatic, server-side)
162+
- Already handled before reaching `AddLightningInvoiceScreen`
163+
164+
NWC `make_invoice` serves as the **middle ground** between Lightning Address (fully automatic) and manual paste (fully manual). It requires one tap to generate + one tap to confirm.
165+
166+
## What's Next (Phase 5+)
167+
168+
- **Phase 5**: Payment notifications (kind 23197 events)
169+
- Future: Consider NWC as fallback when Lightning Address resolution fails
170+
171+
## References
172+
173+
- [Phase 1: Core Library](NWC_PHASE1_IMPLEMENTATION.md)
174+
- [Phase 2: Wallet Management UI](NWC_PHASE2_IMPLEMENTATION.md)
175+
- [Phase 3: Automatic Hold Invoice Payment](NWC_PHASE3_IMPLEMENTATION.md)
176+
- [NIP-47: make_invoice](https://github.com/nostr-protocol/nips/blob/master/47.md#make_invoice)
177+
- [Issue #459](https://github.com/MostroP2P/mobile/issues/459)

lib/features/order/screens/add_lightning_invoice_screen.dart

Lines changed: 124 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ 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/providers/mostro_storage_provider.dart';
89
import 'package:mostro_mobile/data/models/order.dart';
910
import 'package:mostro_mobile/shared/widgets/add_lightning_invoice_widget.dart';
11+
import 'package:mostro_mobile/shared/widgets/nwc_invoice_widget.dart';
1012
import 'package:mostro_mobile/generated/l10n.dart';
1113
import 'package:mostro_mobile/shared/utils/snack_bar_helper.dart';
1214

@@ -24,6 +26,9 @@ class _AddLightningInvoiceScreenState
2426
extends ConsumerState<AddLightningInvoiceScreen> {
2527
final TextEditingController invoiceController = TextEditingController();
2628

29+
/// Whether the user chose to enter the invoice manually (fallback from NWC).
30+
bool _manualMode = false;
31+
2732
@override
2833
Widget build(BuildContext context) {
2934
final orderId = widget.orderId;
@@ -37,6 +42,11 @@ class _AddLightningInvoiceScreenState
3742
final fiatCode = orderPayload?.fiatCode ?? '';
3843
final orderIdValue = orderPayload?.id ?? orderId;
3944

45+
final nwcState = ref.watch(nwcProvider);
46+
final isNwcConnected = nwcState.status == NwcStatus.connected;
47+
final showNwcInvoice =
48+
isNwcConnected && !_manualMode && (amount ?? 0) > 0;
49+
4050
return Scaffold(
4151
backgroundColor: AppTheme.backgroundDark,
4252
appBar: OrderAppBar(title: S.of(context)!.addLightningInvoice),
@@ -49,49 +59,32 @@ class _AddLightningInvoiceScreenState
4959
decoration: BoxDecoration(
5060
color: AppTheme.backgroundCard,
5161
borderRadius: BorderRadius.circular(16),
52-
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
53-
),
54-
child: AddLightningInvoiceWidget(
55-
controller: invoiceController,
56-
onSubmit: () async {
57-
final invoice = invoiceController.text.trim();
58-
if (invoice.isNotEmpty) {
59-
final orderNotifier = ref.read(
60-
orderNotifierProvider(widget.orderId).notifier);
61-
try {
62-
await orderNotifier.sendInvoice(
63-
widget.orderId, invoice, amount);
64-
if (context.mounted) context.go('/');
65-
} catch (e) {
66-
if (context.mounted) {
67-
SnackBarHelper.showTopSnackBar(
68-
context,
69-
'Failed to update invoice: ${e.toString()}',
70-
);
71-
}
72-
}
73-
}
74-
},
75-
onCancel: () async {
76-
final orderNotifier = ref
77-
.read(orderNotifierProvider(widget.orderId).notifier);
78-
try {
79-
await orderNotifier.cancelOrder();
80-
if (context.mounted) context.go('/');
81-
} catch (e) {
82-
if (context.mounted) {
83-
SnackBarHelper.showTopSnackBar(
84-
context,
85-
S.of(context)!.failedToCancelOrder(e.toString()),
86-
);
87-
}
88-
}
89-
},
90-
amount: amount ?? 0,
91-
fiatAmount: fiatAmount,
92-
fiatCode: fiatCode,
93-
orderId: orderIdValue,
62+
border:
63+
Border.all(color: Colors.white.withValues(alpha: 0.1)),
9464
),
65+
child: showNwcInvoice
66+
? _buildNwcInvoiceFlow(
67+
amount: amount ?? 0,
68+
fiatAmount: fiatAmount,
69+
fiatCode: fiatCode,
70+
orderIdValue: orderIdValue,
71+
)
72+
: AddLightningInvoiceWidget(
73+
controller: invoiceController,
74+
onSubmit: () async {
75+
final invoice = invoiceController.text.trim();
76+
if (invoice.isNotEmpty) {
77+
await _submitInvoice(invoice, amount);
78+
}
79+
},
80+
onCancel: () async {
81+
await _cancelOrder();
82+
},
83+
amount: amount ?? 0,
84+
fiatAmount: fiatAmount,
85+
fiatCode: fiatCode,
86+
orderId: orderIdValue,
87+
),
9588
),
9689
),
9790
],
@@ -102,4 +95,93 @@ class _AddLightningInvoiceScreenState
10295
error: (e, st) => Center(child: Text('Error: $e')),
10396
);
10497
}
98+
99+
Widget _buildNwcInvoiceFlow({
100+
required int amount,
101+
required String fiatAmount,
102+
required String fiatCode,
103+
required String orderIdValue,
104+
}) {
105+
return Column(
106+
crossAxisAlignment: CrossAxisAlignment.start,
107+
children: [
108+
Text(
109+
S.of(context)!.pleaseEnterLightningInvoiceFor(
110+
amount.toString(),
111+
fiatCode,
112+
fiatAmount,
113+
orderIdValue,
114+
),
115+
style: const TextStyle(
116+
color: AppTheme.textPrimary,
117+
fontSize: 16,
118+
fontWeight: FontWeight.w500,
119+
),
120+
),
121+
const SizedBox(height: 24),
122+
NwcInvoiceWidget(
123+
sats: amount,
124+
orderId: orderIdValue,
125+
onInvoiceConfirmed: (invoice) async {
126+
await _submitInvoice(invoice, amount);
127+
},
128+
onFallbackToManual: () {
129+
setState(() => _manualMode = true);
130+
},
131+
),
132+
const Spacer(),
133+
Row(
134+
mainAxisAlignment: MainAxisAlignment.center,
135+
children: [
136+
ElevatedButton(
137+
onPressed: () async {
138+
await _cancelOrder();
139+
},
140+
style: ElevatedButton.styleFrom(
141+
foregroundColor: Colors.white,
142+
backgroundColor: Colors.red,
143+
),
144+
child: Text(S.of(context)!.cancel),
145+
),
146+
],
147+
),
148+
],
149+
);
150+
}
151+
152+
Future<void> _submitInvoice(String invoice, int? amount) async {
153+
final orderNotifier =
154+
ref.read(orderNotifierProvider(widget.orderId).notifier);
155+
try {
156+
await orderNotifier.sendInvoice(widget.orderId, invoice, amount);
157+
if (mounted) context.go('/');
158+
} catch (e) {
159+
if (mounted) {
160+
WidgetsBinding.instance.addPostFrameCallback((_) {
161+
SnackBarHelper.showTopSnackBar(
162+
context,
163+
S.of(context)!.failedToUpdateInvoice(e.toString()),
164+
);
165+
});
166+
}
167+
}
168+
}
169+
170+
Future<void> _cancelOrder() async {
171+
final orderNotifier =
172+
ref.read(orderNotifierProvider(widget.orderId).notifier);
173+
try {
174+
await orderNotifier.cancelOrder();
175+
if (mounted) context.go('/');
176+
} catch (e) {
177+
if (mounted) {
178+
WidgetsBinding.instance.addPostFrameCallback((_) {
179+
SnackBarHelper.showTopSnackBar(
180+
context,
181+
S.of(context)!.failedToCancelOrder(e.toString()),
182+
);
183+
});
184+
}
185+
}
186+
}
105187
}

0 commit comments

Comments
 (0)