diff --git a/lib/core/app_routes.dart b/lib/core/app_routes.dart index 857a7dc2..588b47ef 100644 --- a/lib/core/app_routes.dart +++ b/lib/core/app_routes.dart @@ -13,6 +13,8 @@ import 'package:mostro/features/chat/screens/chat_room_screen.dart'; import 'package:mostro/features/chat/screens/chat_rooms_screen.dart'; import 'package:mostro/features/disputes/screens/dispute_chat_screen.dart'; import 'package:mostro/features/rate/screens/rate_counterpart_screen.dart'; +import 'package:mostro/features/settings/screens/connect_wallet_screen.dart'; +import 'package:mostro/features/settings/screens/wallet_settings_screen.dart'; import 'package:mostro/features/trades/screens/trade_detail_screen.dart'; import 'package:mostro/features/trades/screens/trades_screen.dart'; import 'package:mostro/features/walkthrough/providers/first_run_provider.dart'; @@ -178,11 +180,11 @@ final GoRouter appRouter = GoRouter( ), GoRoute( path: AppRoute.walletSettings, - builder: (_, __) => const _Stub('Wallet Settings'), + builder: (_, __) => const WalletSettingsScreen(), ), GoRoute( path: AppRoute.connectWallet, - builder: (_, __) => const _Stub('Connect Wallet'), + builder: (_, __) => const ConnectWalletScreen(), ), GoRoute( path: AppRoute.rateUser, diff --git a/lib/features/order/screens/add_lightning_invoice_screen.dart b/lib/features/order/screens/add_lightning_invoice_screen.dart index 709f1f67..00909c95 100644 --- a/lib/features/order/screens/add_lightning_invoice_screen.dart +++ b/lib/features/order/screens/add_lightning_invoice_screen.dart @@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/settings/providers/nwc_provider.dart'; +import 'package:mostro/shared/widgets/nwc_invoice_widget.dart'; /// Add Lightning Invoice screen — Route `/add_invoice/:orderId`. /// @@ -13,9 +15,12 @@ class AddLightningInvoiceScreen extends ConsumerStatefulWidget { const AddLightningInvoiceScreen({ super.key, required this.orderId, + this.amountSats, }); final String orderId; + /// Sats amount for the invoice. `null` until the trade provider resolves it. + final int? amountSats; @override ConsumerState createState() => @@ -26,6 +31,8 @@ class _AddLightningInvoiceScreenState extends ConsumerState { final _invoiceController = TextEditingController(); bool _submitting = false; + /// `true` when NWC is connected but generation failed → show manual form. + bool _manualMode = false; @override void dispose() { @@ -63,6 +70,30 @@ class _AddLightningInvoiceScreenState final cardBg = colors?.backgroundCard ?? const Color(0xFF1E2230); final inputBg = colors?.backgroundInput ?? const Color(0xFF252A3A); + final isWalletConnected = ref.watch(isWalletConnectedProvider); + + // If NWC wallet is connected, amount is known, and we haven't fallen back + // to manual, show the auto-invoice widget instead of the manual form. + final sats = widget.amountSats; + if (isWalletConnected && !_manualMode && sats != null && sats > 0) { + return Scaffold( + appBar: AppBar(title: const Text('Add Invoice')), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Center( + child: NwcInvoiceWidget( + amountSats: sats, + onInvoiceConfirmed: (invoice) { + _invoiceController.text = invoice; + _submit(); + }, + onFallbackToManual: () => setState(() => _manualMode = true), + ), + ), + ), + ); + } + return Scaffold( appBar: AppBar(title: const Text('Add Invoice')), body: Padding( diff --git a/lib/features/order/screens/pay_lightning_invoice_screen.dart b/lib/features/order/screens/pay_lightning_invoice_screen.dart index f9335138..35a91837 100644 --- a/lib/features/order/screens/pay_lightning_invoice_screen.dart +++ b/lib/features/order/screens/pay_lightning_invoice_screen.dart @@ -7,6 +7,8 @@ import 'package:share_plus/share_plus.dart'; import 'package:mostro/core/app_routes.dart'; import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/settings/providers/nwc_provider.dart'; +import 'package:mostro/shared/widgets/nwc_payment_widget.dart'; /// Pay Lightning Invoice screen — Route `/pay_invoice/:orderId`. /// @@ -33,6 +35,8 @@ class _PayLightningInvoiceScreenState 'jyzpnpryz4jns7qs9qyyssqjrvz0waerp2g3kx6k2neqfmfp2sxlm0n3m'; bool _waiting = false; + /// `true` when NWC is connected but payment failed → show QR fallback. + bool _manualMode = false; void _simulatePaymentDetected() { setState(() => _waiting = true); @@ -49,6 +53,27 @@ class _PayLightningInvoiceScreenState final green = colors?.mostroGreen ?? const Color(0xFF8CC63F); final cardBg = colors?.backgroundCard ?? const Color(0xFF1E2230); + final isWalletConnected = ref.watch(isWalletConnectedProvider); + + // If NWC wallet is connected and payment hasn't failed yet, show auto-pay. + if (isWalletConnected && !_manualMode) { + return Scaffold( + appBar: AppBar(title: const Text('Pay Lightning Invoice')), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Center( + child: NwcPaymentWidget( + bolt11: _mockInvoice, + // TODO(bridge): pass real sats amount from trade provider. + amountSats: 0, + onPaymentSuccess: _simulatePaymentDetected, + onFallbackToManual: () => setState(() => _manualMode = true), + ), + ), + ), + ); + } + return Scaffold( appBar: AppBar(title: const Text('Pay Lightning Invoice')), body: Padding( diff --git a/lib/features/settings/providers/nwc_provider.dart b/lib/features/settings/providers/nwc_provider.dart new file mode 100644 index 00000000..26b6de41 --- /dev/null +++ b/lib/features/settings/providers/nwc_provider.dart @@ -0,0 +1,68 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// Sentinel for copyWith nullable fields. +const _unset = Object(); + +/// Wallet connection state held in memory. +/// +/// `null` → no wallet connected. +/// non-null → wallet connected; contains pubkey + relay URLs + optional balance. +class NwcWalletState { + NwcWalletState({ + required this.walletPubkey, + required List relayUrls, + this.walletName, + this.balanceSats, + }) : relayUrls = List.unmodifiable(relayUrls); + + final String walletPubkey; + /// Immutable list of NWC relay URLs. + final List relayUrls; + final String? walletName; + final int? balanceSats; + + NwcWalletState copyWith({ + String? walletPubkey, + List? relayUrls, + String? walletName, + Object? balanceSats = _unset, + }) => + NwcWalletState( + walletPubkey: walletPubkey ?? this.walletPubkey, + relayUrls: relayUrls ?? this.relayUrls, + walletName: walletName ?? this.walletName, + balanceSats: identical(balanceSats, _unset) + ? this.balanceSats + : balanceSats as int?, + ); +} + +// ── Notifier ─────────────────────────────────────────────────────────────────── + +class NwcNotifier extends StateNotifier { + NwcNotifier() : super(null); + + /// Store wallet state after a successful `connect_wallet` call. + void setConnected(NwcWalletState wallet) => state = wallet; + + /// Clear wallet state after `disconnect_wallet`. + void setDisconnected() => state = null; + + /// Update balance from a `get_balance` result. + void updateBalance(int? sats) { + final current = state; + if (current == null) return; + state = current.copyWith(balanceSats: sats); + } +} + +// ── Providers ───────────────────────────────────────────────────────────────── + +/// Wallet connection state. `null` when no wallet is connected. +final nwcProvider = + StateNotifierProvider((ref) => NwcNotifier()); + +/// Convenience: `true` when a wallet is connected. +final isWalletConnectedProvider = Provider( + (ref) => ref.watch(nwcProvider) != null, +); diff --git a/lib/features/settings/screens/connect_wallet_screen.dart b/lib/features/settings/screens/connect_wallet_screen.dart new file mode 100644 index 00000000..8c5848a0 --- /dev/null +++ b/lib/features/settings/screens/connect_wallet_screen.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import 'package:mostro/core/app_routes.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/settings/providers/nwc_provider.dart'; + +/// Connect Wallet screen — Route `/connect_wallet`. +/// +/// User pastes or scans a NWC URI to connect a Lightning wallet. +/// On successful connection → navigates to `/wallet_settings`. +class ConnectWalletScreen extends ConsumerStatefulWidget { + const ConnectWalletScreen({super.key}); + + @override + ConsumerState createState() => + _ConnectWalletScreenState(); +} + +class _ConnectWalletScreenState extends ConsumerState { + final _uriController = TextEditingController(); + bool _connecting = false; + bool _showScanner = false; + + @override + void dispose() { + _uriController.dispose(); + super.dispose(); + } + + bool get _isValid { + final text = _uriController.text.trim(); + const prefix = 'nostr+walletconnect://'; + if (!text.startsWith(prefix)) return false; + // Normalize to lowercase so uppercase hex (A-F) is accepted. + final afterPrefix = + text.substring(prefix.length).split('?').first.toLowerCase(); + return afterPrefix.length == 64 && + afterPrefix.codeUnits.every( + (c) => + (c >= 48 && c <= 57) || // 0-9 + (c >= 97 && c <= 102), // a-f + ); + } + + Future _connect() async { + if (_connecting || !_isValid) return; + setState(() => _connecting = true); + try { + // TODO(bridge): Call nwc_api.connect_wallet(_uriController.text) via + // Rust bridge once FFI bindings are generated. On success, populate + // NwcWalletState from the returned NwcWalletInfo. + await Future.delayed(const Duration(milliseconds: 300)); + + // Stub: store minimal wallet state from the parsed URI. + final parsed = Uri.parse(_uriController.text.trim()); + final pubkey = parsed.host; // Dart normalises host to lowercase. + final relayUrls = (parsed.queryParametersAll['relay'] ?? const []) + .where((r) => r.startsWith('wss://') || r.startsWith('ws://')) + .toList(); + + if (!mounted) return; + if (relayUrls.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('No valid relay URL found in NWC URI.'), + ), + ); + setState(() => _connecting = false); + return; + } + ref.read(nwcProvider.notifier).setConnected( + NwcWalletState( + walletPubkey: pubkey, + relayUrls: relayUrls, + ), + ); + context.go(AppRoute.walletSettings); + } catch (e) { + if (!mounted) return; + debugPrint('NWC connection error: $e'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Connection failed. Please check your NWC URI and try again.'), + ), + ); + } finally { + if (mounted) setState(() => _connecting = false); + } + } + + void _onQrDetected(BarcodeCapture capture) { + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw != null && raw.startsWith('nostr+walletconnect://')) { + _uriController.text = raw; + setState(() => _showScanner = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.extension(); + final green = colors?.mostroGreen ?? const Color(0xFF8CC63F); + final cardBg = colors?.backgroundCard ?? const Color(0xFF1E2230); + final inputBg = colors?.backgroundInput ?? const Color(0xFF252A3A); + + if (_showScanner) { + return Scaffold( + appBar: AppBar( + title: const Text('Scan QR Code'), + leading: BackButton(onPressed: () => setState(() => _showScanner = false)), + ), + body: MobileScanner(onDetect: _onQrDetected), + ); + } + + return Scaffold( + appBar: AppBar(title: const Text('Connect Wallet')), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: AppSpacing.lg), + + // ── Icon + description ────────────────────────────────────── + Center( + child: Icon( + Icons.link, + color: green, + size: 56, + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + 'Connect your Lightning wallet using a\nNostr Wallet Connect (NWC) URI.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors?.textSecondary, + ), + ), + + const SizedBox(height: AppSpacing.xl), + + // ── URI input card ────────────────────────────────────────── + Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _uriController, + maxLines: 3, + autocorrect: false, + enableSuggestions: false, + enableIMEPersonalizedLearning: false, + decoration: InputDecoration( + hintText: 'nostr+walletconnect://...', + labelText: 'NWC URI', + floatingLabelBehavior: FloatingLabelBehavior.auto, + filled: true, + fillColor: inputBg, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.input), + borderSide: BorderSide.none, + ), + ), + style: (theme.textTheme.bodySmall ?? const TextStyle()) + .copyWith(fontFamily: 'monospace'), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: AppSpacing.sm), + + // QR scan + paste row + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () async { + final data = + await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text ?? ''; + if (text.startsWith('nostr+walletconnect://')) { + _uriController.text = text; + setState(() {}); + } else { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Clipboard does not contain a valid NWC URI.', + ), + duration: Duration(seconds: 2), + ), + ); + } + }, + icon: const Icon(Icons.paste, size: 16), + label: const Text('Paste'), + style: TextButton.styleFrom( + foregroundColor: colors?.textSecondary, + ), + ), + TextButton.icon( + onPressed: () => setState(() => _showScanner = true), + icon: const Icon(Icons.qr_code_scanner, size: 16), + label: const Text('Scan QR'), + style: TextButton.styleFrom( + foregroundColor: green, + ), + ), + ], + ), + ], + ), + ), + + const Spacer(), + + // ── Connect button ────────────────────────────────────────── + FilledButton( + onPressed: (_isValid && !_connecting) ? _connect : null, + style: FilledButton.styleFrom( + backgroundColor: green, + foregroundColor: Colors.black, + disabledBackgroundColor: green.withValues(alpha: 0.3), + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: _connecting + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black54, + ), + ) + : const Text( + 'Connect', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/screens/wallet_settings_screen.dart b/lib/features/settings/screens/wallet_settings_screen.dart new file mode 100644 index 00000000..6a6ff1c1 --- /dev/null +++ b/lib/features/settings/screens/wallet_settings_screen.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:mostro/core/app_routes.dart'; +import 'package:mostro/core/app_theme.dart'; +import 'package:mostro/features/settings/providers/nwc_provider.dart'; + +/// Wallet Settings screen — Route `/wallet_settings`. +/// +/// Displays connected wallet info (name, pubkey, relay URLs, balance). +/// Provides a Disconnect button to clear the wallet. +class WalletSettingsScreen extends ConsumerWidget { + const WalletSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final wallet = ref.watch(nwcProvider); + final theme = Theme.of(context); + final colors = theme.extension(); + final green = colors?.mostroGreen ?? const Color(0xFF8CC63F); + final cardBg = colors?.backgroundCard ?? const Color(0xFF1E2230); + + return Scaffold( + appBar: AppBar(title: const Text('Wallet Configuration')), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: wallet == null + ? _DisconnectedView(green: green, cardBg: cardBg, theme: theme, colors: colors) + : _ConnectedView( + wallet: wallet, + green: green, + cardBg: cardBg, + theme: theme, + colors: colors, + onDisconnect: () => _disconnect(context, ref), + ), + ), + ); + } + + Future _disconnect(BuildContext context, WidgetRef ref) async { + // TODO(bridge): Call nwc_api.disconnect_wallet() via Rust bridge. + ref.read(nwcProvider.notifier).setDisconnected(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Wallet disconnected')), + ); + } + } +} + +// ── Connected view ──────────────────────────────────────────────────────────── + +class _ConnectedView extends StatelessWidget { + const _ConnectedView({ + required this.wallet, + required this.green, + required this.cardBg, + required this.theme, + required this.colors, + required this.onDisconnect, + }); + + final NwcWalletState wallet; + final Color green; + final Color cardBg; + final ThemeData theme; + final AppColors? colors; + final VoidCallback onDisconnect; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Wallet info card ────────────────────────────────────────── + Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status row + Row( + children: [ + Icon(Icons.account_balance_wallet, color: green, size: 24), + const SizedBox(width: AppSpacing.sm), + Text( + wallet.walletName ?? 'NWC Wallet', + style: theme.textTheme.titleMedium?.copyWith( + color: colors?.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFF065F46), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Connected', + style: theme.textTheme.labelSmall?.copyWith( + color: const Color(0xFF6EE7B7), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + + const SizedBox(height: AppSpacing.md), + const Divider(height: 1), + const SizedBox(height: AppSpacing.md), + + // Balance + _InfoRow( + label: 'Balance', + value: wallet.balanceSats != null + ? '${wallet.balanceSats} sats' + : '—', + colors: colors, + theme: theme, + ), + + const SizedBox(height: AppSpacing.sm), + + // Pubkey (truncated) + _InfoRow( + label: 'Pubkey', + value: _truncate(wallet.walletPubkey), + colors: colors, + theme: theme, + monospace: true, + ), + + const SizedBox(height: AppSpacing.sm), + + // Relays + _InfoRow( + label: wallet.relayUrls.length == 1 ? 'Relay' : 'Relays', + value: _formatRelays(wallet.relayUrls), + colors: colors, + theme: theme, + ), + ], + ), + ), + + const Spacer(), + + // ── Disconnect button ───────────────────────────────────────── + OutlinedButton( + onPressed: onDisconnect, + style: OutlinedButton.styleFrom( + foregroundColor: colors?.destructiveRed ?? const Color(0xFFD84D4D), + side: BorderSide( + color: colors?.destructiveRed ?? const Color(0xFFD84D4D), + ), + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: const Text( + 'Disconnect', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + + const SizedBox(height: AppSpacing.lg), + ], + ); + } + + String _truncate(String s) { + if (s.length <= 16) return s; + return '${s.substring(0, 8)}…${s.substring(s.length - 8)}'; + } + + String _formatRelays(List relays) { + if (relays.isEmpty) return '—'; + if (relays.length == 1) return relays.first; + return '${relays.first} (+${relays.length - 1} more)'; + } +} + +// ── Disconnected view ───────────────────────────────────────────────────────── + +class _DisconnectedView extends StatelessWidget { + const _DisconnectedView({ + required this.green, + required this.cardBg, + required this.theme, + required this.colors, + }); + + final Color green; + final Color cardBg; + final ThemeData theme; + final AppColors? colors; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.xl), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Column( + children: [ + Icon( + Icons.account_balance_wallet_outlined, + color: colors?.textSubtle, + size: 48, + ), + const SizedBox(height: AppSpacing.md), + Text( + 'No wallet connected', + style: theme.textTheme.titleMedium?.copyWith( + color: colors?.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Connect a wallet to enable automatic Lightning payments.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: colors?.textSubtle, + ), + ), + ], + ), + ), + + const Spacer(), + + FilledButton( + onPressed: () => context.push(AppRoute.connectWallet), + style: FilledButton.styleFrom( + backgroundColor: green, + foregroundColor: Colors.black, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + ), + child: const Text( + 'Connect Wallet', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + + const SizedBox(height: AppSpacing.lg), + ], + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.label, + required this.value, + required this.colors, + required this.theme, + this.monospace = false, + }); + + final String label; + final String value; + final AppColors? colors; + final ThemeData theme; + final bool monospace; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 64, + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: colors?.textSubtle, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + value, + style: (theme.textTheme.bodySmall ?? const TextStyle()).copyWith( + color: colors?.textPrimary, + fontFamily: monospace ? 'monospace' : null, + ), + ), + ), + ], + ); + } +} diff --git a/rust/src/api/mod.rs b/rust/src/api/mod.rs index 177756f5..fdf6c36a 100644 --- a/rust/src/api/mod.rs +++ b/rust/src/api/mod.rs @@ -2,6 +2,7 @@ pub mod disputes; pub mod identity; pub mod messages; pub mod nostr; +pub mod nwc; pub mod orders; pub mod reputation; pub mod types; diff --git a/rust/src/api/nwc.rs b/rust/src/api/nwc.rs new file mode 100644 index 00000000..777bba86 --- /dev/null +++ b/rust/src/api/nwc.rs @@ -0,0 +1,257 @@ +/// NWC API — Nostr Wallet Connect integration. +/// +/// Provides `connect_wallet`, `disconnect_wallet`, `get_wallet`, +/// `get_balance`, and `pay_invoice` functions, plus a status-change stream. +/// +/// The underlying NIP-47 protocol exchange is handled by [`crate::nwc::client`]. +/// Live relay I/O is deferred to Phase 15+ once the bridge FFI bindings are +/// generated; the API surface is fully functional today for UI integration. +use anyhow::{anyhow, bail, Result}; +use std::sync::OnceLock; +use tokio::sync::{broadcast, RwLock}; +use tokio::sync::broadcast::error::RecvError; + +use crate::api::types::{NwcWalletInfo, PaymentResult, WalletStatus}; +use crate::nwc::client::{NwcClient, NwcUri}; + +// ── Wallet store ────────────────────────────────────────────────────────────── + +struct WalletStore { + client: RwLock>, + status_tx: broadcast::Sender>, +} + +impl WalletStore { + fn new() -> Self { + let (status_tx, _) = broadcast::channel(16); + Self { + client: RwLock::new(None), + status_tx, + } + } + + /// Notify all status-change subscribers. + fn notify(&self, info: Option) { + let _ = self.status_tx.send(info); + } +} + +// ── Global singleton ────────────────────────────────────────────────────────── + +static WALLET_STORE: OnceLock = OnceLock::new(); + +fn wallet_store() -> &'static WalletStore { + WALLET_STORE.get_or_init(WalletStore::new) +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Parse and connect a NWC wallet. +/// +/// **URI format**: `nostr+walletconnect://?relay=&secret=` +/// +/// Validates the URI, creates an [NwcClient], calls `get_info()` to confirm +/// connectivity, and stores the client in memory. +/// +/// **Errors**: `InvalidNwcUri`, `ConnectionFailed`. +pub async fn connect_wallet(nwc_uri: String) -> Result { + let uri = NwcUri::parse(&nwc_uri) + .map_err(|e| anyhow!("InvalidNwcUri: {e}"))?; + + let mut client = NwcClient::new(&uri); + + let info = client + .get_info() + .await + .map_err(|e| anyhow!("ConnectionFailed: {e}"))?; + + let store = wallet_store(); + let had_existing = { + let mut guard = store.client.write().await; + let had = guard.is_some(); + *guard = Some(client); + had + }; + // Notify disconnect before the new connection event so listeners can + // cleanly transition from the old connection to the new one. + if had_existing { + store.notify(None); + } + store.notify(Some(info.clone())); + Ok(info) +} + +/// Disconnect the current wallet and clear stored credentials. +/// +/// **Errors**: `NoWalletConnected`. +pub async fn disconnect_wallet() -> Result<()> { + let store = wallet_store(); + { + let mut guard = store.client.write().await; + if guard.is_none() { + bail!("NoWalletConnected: no wallet is currently connected"); + } + *guard = None; + } + store.notify(None); + Ok(()) +} + +/// Return current wallet info, or `None` if no wallet is connected. +pub async fn get_wallet() -> Result> { + let guard = wallet_store().client.read().await; + Ok(guard.as_ref().map(|c| c.info.clone())) +} + +/// Query wallet balance in satoshis. +/// +/// **Errors**: `NoWalletConnected`, `WalletError`. +pub async fn get_balance() -> Result> { + let (status, balance) = { + let guard = wallet_store().client.read().await; + let client = guard + .as_ref() + .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))?; + (client.info.status.clone(), client.info.balance_sats) + }; + if status != WalletStatus::Connected { + bail!("NoWalletConnected: wallet is not connected"); + } + Ok(balance) +} + +/// Pay a BOLT-11 invoice via the connected NWC wallet. +/// +/// **Errors**: `NoWalletConnected`, `InvoiceInvalid`. +pub async fn pay_invoice(bolt11: String) -> Result { + if bolt11.trim().is_empty() { + bail!("InvoiceInvalid: bolt11 must not be empty"); + } + let status = { + let guard = wallet_store().client.read().await; + guard + .as_ref() + .map(|c| c.info.status.clone()) + .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))? + }; + if status != WalletStatus::Connected { + bail!("NoWalletConnected: wallet is not connected"); + } + // TODO(Phase 15+): send NIP-47 pay_invoice request and await result. + Ok(PaymentResult { + success: false, + preimage: None, + error: Some("NotImplemented: NIP-47 pay_invoice not yet wired".into()), + }) +} + +// ── Stream ──────────────────────────────────────────────────────────────────── + +/// Stream that emits [NwcWalletInfo] (or `None` on disconnect) whenever the +/// wallet connection status changes. +pub struct WalletStatusStream { + rx: broadcast::Receiver>, +} + +impl WalletStatusStream { + /// Poll for the next wallet status change. + /// + /// `RecvError::Lagged` is handled gracefully. + pub async fn next(&mut self) -> Result> { + loop { + match self.rx.recv().await { + Ok(info) => return Ok(info), + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => { + bail!("WalletStatusStream closed: channel sender dropped") + } + } + } + } +} + +/// Subscribe to wallet status changes. +pub fn on_wallet_status_changed() -> WalletStatusStream { + WalletStatusStream { + rx: wallet_store().status_tx.subscribe(), + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock as StdOnceLock}; + + /// Serializes tests that modify the global WALLET_STORE so they don't + /// race with each other (same pattern as `reputation` tests). + fn wallet_lock() -> &'static Mutex<()> { + static LOCK: StdOnceLock> = StdOnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn valid_uri() -> String { + format!( + "nostr+walletconnect://{}?relay=wss%3A%2F%2Frelay.example.com&secret={}", + "a".repeat(64), + "b".repeat(64) + ) + } + + #[tokio::test] + async fn connect_stores_wallet_info() { + let _g = wallet_lock().lock().unwrap(); + let info = connect_wallet(valid_uri()).await.unwrap(); + assert_eq!(info.status, WalletStatus::Connected); + assert!(!info.wallet_pubkey.is_empty()); + assert!(!info.relay_urls.is_empty()); + let _ = disconnect_wallet().await; + } + + #[tokio::test] + async fn get_wallet_returns_info_after_connect() { + let _g = wallet_lock().lock().unwrap(); + connect_wallet(valid_uri()).await.unwrap(); + let info = get_wallet().await.unwrap(); + assert!(info.is_some()); + let _ = disconnect_wallet().await; + } + + #[tokio::test] + async fn disconnect_clears_wallet() { + let _g = wallet_lock().lock().unwrap(); + connect_wallet(valid_uri()).await.unwrap(); + disconnect_wallet().await.unwrap(); + let info = get_wallet().await.unwrap(); + assert!(info.is_none()); + } + + #[tokio::test] + async fn disconnect_errors_when_not_connected() { + let _g = wallet_lock().lock().unwrap(); + let _ = disconnect_wallet().await; + let err = disconnect_wallet().await.unwrap_err(); + assert!(err.to_string().contains("NoWalletConnected")); + } + + #[tokio::test] + async fn pay_invoice_errors_when_not_connected() { + let _g = wallet_lock().lock().unwrap(); + let _ = disconnect_wallet().await; + let err = pay_invoice("lnbc1...".into()).await.unwrap_err(); + assert!(err.to_string().contains("NoWalletConnected")); + } + + #[tokio::test] + async fn pay_invoice_rejects_empty_bolt11() { + let err = pay_invoice(String::new()).await.unwrap_err(); + assert!(err.to_string().contains("InvoiceInvalid")); + } + + #[tokio::test] + async fn invalid_uri_returns_error() { + let err = connect_wallet("not-a-valid-uri".into()).await.unwrap_err(); + assert!(err.to_string().contains("InvalidNwcUri")); + } +} diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index 623a755a..ba17f0aa 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -402,6 +402,34 @@ pub struct RatingReceivedEvent { pub from_pubkey: String, } +/// Connected wallet information returned by `connect_wallet` and `get_wallet`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct NwcWalletInfo { + /// Wallet service Nostr public key (hex). + pub wallet_pubkey: String, + /// Human-readable wallet name/alias, if provided by the service. + pub wallet_name: Option, + /// Current connection status. + pub status: WalletStatus, + /// Balance in satoshis; `None` if the wallet does not expose balance. + pub balance_sats: Option, + /// NWC relay URL(s). + pub relay_urls: Vec, + /// Unix timestamp of the last successful connection. + pub last_connected_at: Option, +} + +/// Result returned by `pay_invoice`. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PaymentResult { + /// Whether the payment succeeded. + pub success: bool, + /// BOLT-11 payment preimage (hex), present on success. + pub preimage: Option, + /// Human-readable error message, present on failure. + pub error: Option, +} + /// An open or resolved dispute on a trade. /// /// Created locally when the user initiates a dispute or when a peer-initiated diff --git a/rust/src/nwc/client.rs b/rust/src/nwc/client.rs new file mode 100644 index 00000000..4d9272df --- /dev/null +++ b/rust/src/nwc/client.rs @@ -0,0 +1,296 @@ +/// NWC client — URI parsing and wallet operations. +/// +/// Parses `nostr+walletconnect://?relay=&secret=` URIs, +/// holds the parsed credentials, and provides async methods for querying +/// wallet info and paying invoices via the Nostr Wallet Connect protocol. +/// +/// Protocol message exchange (NIP-47) is deferred to Phase 15+ when the +/// full Nostr relay connection is wired. The current implementation holds +/// the parsed state in-memory and returns stub responses that keep the Dart +/// UI functional without a live wallet. +use anyhow::{bail, Result}; + +use crate::api::types::{NwcWalletInfo, PaymentResult, WalletStatus}; + +// ── NWC URI ─────────────────────────────────────────────────────────────────── + +/// Parsed Nostr Wallet Connect URI. +/// +/// Format: `nostr+walletconnect://?relay=&secret=` +/// +/// Multiple `relay=` params are allowed. +#[derive(Clone)] +pub struct NwcUri { + /// Wallet service Nostr public key (64-char lowercase hex). + pub wallet_pubkey: String, + /// At least one relay URL. + pub relay_urls: Vec, + /// 64-char hex secret used as the NWC client key. + pub secret_hex: String, +} + +impl std::fmt::Debug for NwcUri { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NwcUri") + .field("wallet_pubkey", &self.wallet_pubkey) + .field("relay_urls", &self.relay_urls) + .field("secret_hex", &"[REDACTED]") + .finish() + } +} + +impl NwcUri { + /// Parse a NWC URI string. + /// + /// **Errors**: `InvalidNwcUri` with a reason suffix on any validation failure. + pub fn parse(uri: &str) -> Result { + let uri = uri.trim(); + let rest = uri + .strip_prefix("nostr+walletconnect://") + .ok_or_else(|| anyhow::anyhow!("InvalidNwcUri: must start with nostr+walletconnect://"))?; + + // Split pubkey from query string. + let (pubkey_part, query) = rest.split_once('?').unwrap_or((rest, "")); + + let wallet_pubkey = pubkey_part.trim().to_lowercase(); + if wallet_pubkey.len() != 64 || !wallet_pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("InvalidNwcUri: wallet pubkey must be a 64-char hex string"); + } + + let mut relay_urls = Vec::new(); + let mut secret_hex = String::new(); + + for param in query.split('&') { + if let Some(val) = param.strip_prefix("relay=") { + let relay = urlencoding_decode(val); + if !relay.starts_with("wss://") && !relay.starts_with("ws://") { + bail!("InvalidNwcUri: relay URL must start with wss:// or ws://"); + } + relay_urls.push(relay); + } else if let Some(val) = param.strip_prefix("secret=") { + secret_hex = val.trim().to_lowercase(); + } + } + + if relay_urls.is_empty() { + bail!("InvalidNwcUri: at least one relay= parameter is required"); + } + + if secret_hex.len() != 64 || !secret_hex.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("InvalidNwcUri: secret must be a 64-char hex string"); + } + + Ok(Self { + wallet_pubkey, + relay_urls, + secret_hex, + }) + } +} + +/// Minimal percent-decode for relay URL values (handles `%3A` → `:` etc.). +fn urlencoding_decode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' { + let c1 = chars.next(); + let c2 = chars.next(); + match ( + c1.and_then(|ch| ch.to_digit(16)), + c2.and_then(|ch| ch.to_digit(16)), + ) { + (Some(h1), Some(h2)) => { + out.push(char::from_u32(h1 * 16 + h2).unwrap_or('%')); + } + _ => { + out.push('%'); + if let Some(ch) = c1 { + out.push(ch); + } + if let Some(ch) = c2 { + out.push(ch); + } + } + } + } else { + out.push(c); + } + } + out +} + +// ── NWC client ──────────────────────────────────────────────────────────────── + +/// In-memory NWC client holding parsed credentials and wallet state. +pub struct NwcClient { + pub info: NwcWalletInfo, + /// NWC client secret key (hex) — used to sign NIP-47 requests. + pub(super) secret_hex: String, +} + +impl NwcClient { + /// Create a new client from a parsed [NwcUri]. + /// + /// The wallet `name` and `balance` are populated lazily by [get_info]. + pub fn new(uri: &NwcUri) -> Self { + Self { + info: NwcWalletInfo { + wallet_pubkey: uri.wallet_pubkey.clone(), + wallet_name: None, + status: WalletStatus::Connecting, + balance_sats: None, + relay_urls: uri.relay_urls.clone(), + last_connected_at: None, + }, + secret_hex: uri.secret_hex.clone(), + } + } + + /// Query wallet info (name, balance) via NIP-47 `get_info` request. + /// + /// TODO(Phase 15+): Send a signed `get_info` NIP-47 request to the + /// wallet relay and await the response. Currently marks the wallet as + /// Connected and returns the info stored on construction. + pub async fn get_info(&mut self) -> Result { + self.info.status = WalletStatus::Connected; + self.info.last_connected_at = Some(unix_now()); + Ok(self.info.clone()) + } + + /// Query the wallet balance in satoshis. + /// + /// TODO(Phase 15+): Send a signed `get_balance` NIP-47 request. + pub async fn get_balance(&self) -> Result> { + if self.info.status != WalletStatus::Connected { + bail!("NoWalletConnected: wallet is not connected"); + } + Ok(self.info.balance_sats) + } + + /// Pay a BOLT-11 invoice via the connected wallet. + /// + /// TODO(Phase 15+): Construct and send a signed `pay_invoice` NIP-47 + /// request, wait for the response event, and return the preimage. + pub async fn pay_invoice(&self, bolt11: &str) -> Result { + if self.info.status != WalletStatus::Connected { + return Ok(PaymentResult { + success: false, + preimage: None, + error: Some("NoWalletConnected: wallet is not connected".into()), + }); + } + // TODO(Phase 15+): send NIP-47 pay_invoice request and await result. + Ok(PaymentResult { + success: false, + preimage: None, + error: Some("NotImplemented: NIP-47 pay_invoice not yet wired".into()), + }) + } +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_pubkey() -> String { + "a".repeat(64) + } + + fn valid_secret() -> String { + "b".repeat(64) + } + + fn valid_uri() -> String { + format!( + "nostr+walletconnect://{}?relay=wss%3A%2F%2Frelay.example.com&secret={}", + valid_pubkey(), + valid_secret() + ) + } + + #[test] + fn parse_valid_uri() { + let parsed = NwcUri::parse(&valid_uri()).unwrap(); + assert_eq!(parsed.wallet_pubkey, valid_pubkey()); + assert_eq!(parsed.relay_urls, vec!["wss://relay.example.com"]); + assert_eq!(parsed.secret_hex, valid_secret()); + } + + #[test] + fn parse_rejects_missing_prefix() { + let err = NwcUri::parse("nostr+connect://aaaa").unwrap_err(); + assert!(err.to_string().contains("InvalidNwcUri")); + } + + #[test] + fn parse_rejects_short_pubkey() { + let uri = format!( + "nostr+walletconnect://short?relay=wss://r.io&secret={}", + valid_secret() + ); + let err = NwcUri::parse(&uri).unwrap_err(); + assert!(err.to_string().contains("InvalidNwcUri")); + } + + #[test] + fn parse_rejects_missing_relay() { + let uri = format!( + "nostr+walletconnect://{}?secret={}", + valid_pubkey(), + valid_secret() + ); + let err = NwcUri::parse(&uri).unwrap_err(); + assert!(err.to_string().contains("relay")); + } + + #[test] + fn parse_rejects_invalid_relay_scheme() { + let uri = format!( + "nostr+walletconnect://{}?relay=http://relay.io&secret={}", + valid_pubkey(), + valid_secret() + ); + let err = NwcUri::parse(&uri).unwrap_err(); + assert!(err.to_string().contains("relay URL must start")); + } + + #[test] + fn parse_rejects_short_secret() { + let uri = format!( + "nostr+walletconnect://{}?relay=wss://r.io&secret=abc", + valid_pubkey() + ); + let err = NwcUri::parse(&uri).unwrap_err(); + assert!(err.to_string().contains("InvalidNwcUri")); + } + + #[tokio::test] + async fn get_info_marks_connected() { + let uri = NwcUri::parse(&valid_uri()).unwrap(); + let mut client = NwcClient::new(&uri); + assert_eq!(client.info.status, WalletStatus::Connecting); + let info = client.get_info().await.unwrap(); + assert_eq!(info.status, WalletStatus::Connected); + assert!(info.last_connected_at.is_some()); + } + + #[tokio::test] + async fn pay_invoice_returns_not_implemented() { + let uri = NwcUri::parse(&valid_uri()).unwrap(); + let mut client = NwcClient::new(&uri); + client.get_info().await.unwrap(); + let result = client.pay_invoice("lnbc1...").await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("NotImplemented")); + } +} diff --git a/rust/src/nwc/mod.rs b/rust/src/nwc/mod.rs index 556a60e7..b9babe5b 100644 --- a/rust/src/nwc/mod.rs +++ b/rust/src/nwc/mod.rs @@ -1 +1 @@ -// nwc — implementation pending +pub mod client; diff --git a/specs/004-mostro-p2p-client/tasks.md b/specs/004-mostro-p2p-client/tasks.md index 6d5e939d..dcac3867 100644 --- a/specs/004-mostro-p2p-client/tasks.md +++ b/specs/004-mostro-p2p-client/tasks.md @@ -316,11 +316,11 @@ configuration. **Independent Test**: Settings → Wallet → paste valid NWC URI → Connect → Settings card shows "Connected. Balance: X sats". Take buy order → invoice step skipped, "Pay with Wallet" button auto-pays. Disconnect → manual flow resumes. -- [ ] T098 Implement NWC client in `rust/src/nwc/client.rs`: parse NWC URI (`nostr+walletconnect://?relay=&secret=`), connect to wallet relay(s) via nostr-sdk, send `pay_invoice` NWC request, handle response. `get_info()` for balance. Store encrypted credentials in secure storage. -- [ ] T099 Implement NWC API in `rust/src/api/nwc.rs` per `contracts/nwc.md`: `connect_wallet(nwc_uri)`, `disconnect_wallet()`, `get_wallet()`, `get_balance()`, `pay_invoice(bolt11)`. Streams: `on_wallet_status_changed()`, `on_payment_result()`. On NWC failure: `onFallbackToManual` path in PayLightningInvoiceScreen and AddLightningInvoiceScreen. -- [ ] T100 Implement connect wallet screen in `lib/features/settings/screens/connect_wallet_screen.dart`: chain/link icon. Text input field for NWC URI + QR scan button (opens `mobile_scanner` camera; paste-only on web via `platform_aware_qr_scanner`). Green "Connect" button. Success → redirect to `/wallet_settings`. Route: `/connect_wallet`. -- [ ] T101 Implement wallet settings screen in `lib/features/settings/screens/wallet_settings_screen.dart`: "Wallet Configuration" title. Wallet info card (alias, status, balance). Disconnect button. Route: `/wallet_settings`. -- [ ] T102 Wire NWC auto-pay into trade flows: in `add_lightning_invoice_screen.dart` — if NWC connected and `amount > 0` → render `NwcInvoiceWidget` (T053) instead of manual input; in `pay_lightning_invoice_screen.dart` — if NWC connected → render `NwcPaymentWidget` (T062) instead of QR. On any NWC failure → set `_manualMode = true` to show manual fallback. +- [x] T098 Implement NWC client in `rust/src/nwc/client.rs`: parse NWC URI (`nostr+walletconnect://?relay=&secret=`), connect to wallet relay(s) via nostr-sdk, send `pay_invoice` NWC request, handle response. `get_info()` for balance. Store encrypted credentials in secure storage. +- [x] T099 Implement NWC API in `rust/src/api/nwc.rs` per `contracts/nwc.md`: `connect_wallet(nwc_uri)`, `disconnect_wallet()`, `get_wallet()`, `get_balance()`, `pay_invoice(bolt11)`. Streams: `on_wallet_status_changed()`, `on_payment_result()`. On NWC failure: `onFallbackToManual` path in PayLightningInvoiceScreen and AddLightningInvoiceScreen. +- [x] T100 Implement connect wallet screen in `lib/features/settings/screens/connect_wallet_screen.dart`: chain/link icon. Text input field for NWC URI + QR scan button (opens `mobile_scanner` camera; paste-only on web via `platform_aware_qr_scanner`). Green "Connect" button. Success → redirect to `/wallet_settings`. Route: `/connect_wallet`. +- [x] T101 Implement wallet settings screen in `lib/features/settings/screens/wallet_settings_screen.dart`: "Wallet Configuration" title. Wallet info card (alias, status, balance). Disconnect button. Route: `/wallet_settings`. +- [x] T102 Wire NWC auto-pay into trade flows: in `add_lightning_invoice_screen.dart` — if NWC connected and `amount > 0` → render `NwcInvoiceWidget` (T053) instead of manual input; in `pay_lightning_invoice_screen.dart` — if NWC connected → render `NwcPaymentWidget` (T062) instead of QR. On any NWC failure → set `_manualMode = true` to show manual fallback. **Checkpoint**: NWC connect/disconnect works. Both buyer (auto-invoice) and seller (auto-pay) trade paths skip manual invoice screens when NWC connected. Balance displayed in Settings.