diff --git a/lib/features/settings/providers/nwc_provider.dart b/lib/features/settings/providers/nwc_provider.dart index 26b6de41..f3796e76 100644 --- a/lib/features/settings/providers/nwc_provider.dart +++ b/lib/features/settings/providers/nwc_provider.dart @@ -1,8 +1,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; // Sentinel for copyWith nullable fields. const _unset = Object(); +/// SharedPreferences key for the persisted NWC URI. +const kNwcUriKey = 'settings.nwcUri'; + /// Wallet connection state held in memory. /// /// `null` → no wallet connected. @@ -40,13 +44,24 @@ class NwcWalletState { // ── Notifier ─────────────────────────────────────────────────────────────────── class NwcNotifier extends StateNotifier { - NwcNotifier() : super(null); + NwcNotifier({SharedPreferences? prefs}) : _prefs = prefs, super(null); + + final SharedPreferences? _prefs; /// Store wallet state after a successful `connect_wallet` call. - void setConnected(NwcWalletState wallet) => state = wallet; + /// Persists the NWC URI so it survives app restarts. + void setConnected(NwcWalletState wallet, {String? nwcUri}) { + state = wallet; + if (nwcUri != null) { + _prefs?.setString(kNwcUriKey, nwcUri); + } + } /// Clear wallet state after `disconnect_wallet`. - void setDisconnected() => state = null; + void setDisconnected() { + state = null; + _prefs?.remove(kNwcUriKey); + } /// Update balance from a `get_balance` result. void updateBalance(int? sats) { @@ -59,6 +74,7 @@ class NwcNotifier extends StateNotifier { // ── Providers ───────────────────────────────────────────────────────────────── /// Wallet connection state. `null` when no wallet is connected. +/// Override in `main()` via [ProviderScope] to inject [SharedPreferences]. final nwcProvider = StateNotifierProvider((ref) => NwcNotifier()); diff --git a/lib/features/settings/screens/connect_wallet_screen.dart b/lib/features/settings/screens/connect_wallet_screen.dart index acc3f1ef..d7e1cfcb 100644 --- a/lib/features/settings/screens/connect_wallet_screen.dart +++ b/lib/features/settings/screens/connect_wallet_screen.dart @@ -60,6 +60,7 @@ class _ConnectWalletScreenState extends ConsumerState { walletName: info.walletName, balanceSats: info.balanceSats?.toInt(), ), + nwcUri: _uriController.text.trim(), ); context.go(AppRoute.walletSettings); } catch (e) { diff --git a/lib/features/settings/screens/settings_screen.dart b/lib/features/settings/screens/settings_screen.dart index d7d00e9d..8c06658d 100644 --- a/lib/features/settings/screens/settings_screen.dart +++ b/lib/features/settings/screens/settings_screen.dart @@ -33,6 +33,11 @@ class _SettingsScreenState extends ConsumerState { return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context).settingsScreenTitle), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => + context.canPop() ? context.pop() : context.go(AppRoute.home), + ), ), body: ListView( padding: const EdgeInsets.all(AppSpacing.lg), diff --git a/lib/features/settings/screens/wallet_settings_screen.dart b/lib/features/settings/screens/wallet_settings_screen.dart index 2f60e012..720d5ddb 100644 --- a/lib/features/settings/screens/wallet_settings_screen.dart +++ b/lib/features/settings/screens/wallet_settings_screen.dart @@ -23,7 +23,14 @@ class WalletSettingsScreen extends ConsumerWidget { final cardBg = colors?.backgroundCard ?? const Color(0xFF1E2230); return Scaffold( - appBar: AppBar(title: const Text('Wallet Configuration')), + appBar: AppBar( + title: const Text('Wallet Configuration'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => + context.canPop() ? context.pop() : context.go(AppRoute.settings), + ), + ), body: Padding( padding: const EdgeInsets.all(AppSpacing.lg), child: wallet == null diff --git a/lib/main.dart b/lib/main.dart index 68cff47d..2000c118 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,8 @@ import 'package:mostro/features/walkthrough/providers/first_run_provider.dart'; import 'package:mostro/features/account/providers/backup_reminder_provider.dart'; import 'package:mostro/src/rust/frb_generated.dart'; import 'package:mostro/src/rust/api.dart' as rust_api; +import 'package:mostro/features/settings/providers/nwc_provider.dart'; +import 'package:mostro/src/rust/api/nwc.dart' as nwc_api; import 'package:mostro/src/rust/api/nostr.dart' as nostr_api; import 'package:mostro/src/rust/api/orders.dart' as orders_api; @@ -58,7 +60,7 @@ Future main() async { // Watch for connection state changes in background (logs appear in flutter output). _watchConnectionState(); - runApp(ProviderScope( + final container = ProviderContainer( overrides: [ firstRunProvider.overrideWith( (ref) => FirstRunNotifier(initialValue: firstRunComplete), @@ -69,11 +71,44 @@ Future main() async { settingsProvider.overrideWith( (ref) => SettingsNotifier(prefs: prefs, initial: savedSettings), ), + nwcProvider.overrideWith( + (ref) => NwcNotifier(prefs: prefs), + ), ], + ); + + // Restore NWC wallet connection if a URI was saved from a previous session. + final savedNwcUri = prefs.getString(kNwcUriKey); + if (savedNwcUri != null) { + _restoreNwcConnection(savedNwcUri, container); + } + + runApp(UncontrolledProviderScope( + container: container, child: const MostroApp(), )); } +/// Reconnect a previously saved NWC wallet in the background. +void _restoreNwcConnection(String nwcUri, ProviderContainer container) { + Future.microtask(() async { + try { + final info = await nwc_api.connectWallet(nwcUri: nwcUri); + container.read(nwcProvider.notifier).setConnected( + NwcWalletState( + walletPubkey: info.walletPubkey, + relayUrls: info.relayUrls, + walletName: info.walletName, + balanceSats: info.balanceSats?.toInt(), + ), + ); + debugPrint('[nwc] wallet restored: ${info.walletName ?? info.walletPubkey}'); + } catch (e) { + debugPrint('[nwc] wallet restore failed: $e'); + } + }); +} + /// Guards against overlapping diagnostic order polls on rapid reconnects. bool _isPollingOrders = false; diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 6fb15187..3a2d5287 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -39,6 +39,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -1653,6 +1664,7 @@ version = "0.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" dependencies = [ + "aes", "base64", "bech32", "bip39", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ce573d65..d61d5d86 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -10,7 +10,7 @@ crate-type = ["cdylib", "staticlib"] flutter_rust_bridge = "=2.11.1" # Nostr & Mostro protocol -nostr-sdk = { version = "0.44", default-features = false, features = ["nip44", "nip59"] } +nostr-sdk = { version = "0.44", default-features = false, features = ["nip44", "nip47", "nip59"] } mostro-core = "0.8.0" # Serialization diff --git a/rust/src/api/nwc.rs b/rust/src/api/nwc.rs index 777bba86..b9f9431a 100644 --- a/rust/src/api/nwc.rs +++ b/rust/src/api/nwc.rs @@ -4,20 +4,18 @@ /// `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 std::sync::{Arc, 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}; +use crate::api::types::{NwcWalletInfo, PaymentResult}; +use crate::nwc::client::NwcClient; // ── Wallet store ────────────────────────────────────────────────────────────── struct WalletStore { - client: RwLock>, + client: RwLock>>, status_tx: broadcast::Sender>, } @@ -55,23 +53,34 @@ fn wallet_store() -> &'static WalletStore { /// /// **Errors**: `InvalidNwcUri`, `ConnectionFailed`. pub async fn connect_wallet(nwc_uri: String) -> Result { - let uri = NwcUri::parse(&nwc_uri) + let mut client = NwcClient::new(&nwc_uri) + .await .map_err(|e| anyhow!("InvalidNwcUri: {e}"))?; - let mut client = NwcClient::new(&uri); - let info = client .get_info() .await .map_err(|e| anyhow!("ConnectionFailed: {e}"))?; + // Fetch initial balance (non-fatal — some wallets don't support it). + let balance = client.get_balance().await.ok().flatten(); + let info = NwcWalletInfo { + balance_sats: balance, + ..info + }; + let store = wallet_store(); - let had_existing = { + let (had_existing, old_client) = { let mut guard = store.client.write().await; - let had = guard.is_some(); - *guard = Some(client); - had + let old = guard.take(); + let had = old.is_some(); + *guard = Some(Arc::new(client)); + (had, old) }; + // Disconnect the old client outside the lock. + if let Some(old) = old_client { + old.disconnect().await; + } // Notify disconnect before the new connection event so listeners can // cleanly transition from the old connection to the new one. if had_existing { @@ -86,13 +95,13 @@ pub async fn connect_wallet(nwc_uri: String) -> Result { /// **Errors**: `NoWalletConnected`. pub async fn disconnect_wallet() -> Result<()> { let store = wallet_store(); - { + let old = { let mut guard = store.client.write().await; - if guard.is_none() { - bail!("NoWalletConnected: no wallet is currently connected"); - } - *guard = None; - } + guard + .take() + .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))? + }; + old.disconnect().await; store.notify(None); Ok(()) } @@ -103,21 +112,19 @@ pub async fn get_wallet() -> Result> { Ok(guard.as_ref().map(|c| c.info.clone())) } -/// Query wallet balance in satoshis. +/// Query wallet balance in satoshis (live from the wallet, not cached). /// /// **Errors**: `NoWalletConnected`, `WalletError`. pub async fn get_balance() -> Result> { - let (status, balance) = { + let client = { 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) + Arc::clone( + guard + .as_ref() + .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))?, + ) }; - if status != WalletStatus::Connected { - bail!("NoWalletConnected: wallet is not connected"); - } - Ok(balance) + client.get_balance().await } /// Pay a BOLT-11 invoice via the connected NWC wallet. @@ -127,22 +134,17 @@ pub async fn pay_invoice(bolt11: String) -> Result { if bolt11.trim().is_empty() { bail!("InvoiceInvalid: bolt11 must not be empty"); } - let status = { + + let client = { 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"))? + Arc::clone( + guard + .as_ref() + .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()), - }) + + client.pay_invoice(&bolt11).await } // ── Stream ──────────────────────────────────────────────────────────────────── @@ -180,8 +182,10 @@ pub fn on_wallet_status_changed() -> WalletStatusStream { // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] +#[allow(clippy::await_holding_lock)] mod tests { use super::*; + use crate::api::types::WalletStatus; use std::sync::{Mutex, OnceLock as StdOnceLock}; /// Serializes tests that modify the global WALLET_STORE so they don't @@ -191,67 +195,77 @@ mod tests { 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 pay_invoice_rejects_empty_bolt11() { + let err = pay_invoice(String::new()).await.unwrap_err(); + assert!(err.to_string().contains("InvoiceInvalid")); } #[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; + async fn invalid_uri_returns_error() { + let err = connect_wallet("not-a-valid-uri".into()).await.unwrap_err(); + assert!(err.to_string().contains("InvalidNwcUri")); } #[tokio::test] - async fn get_wallet_returns_info_after_connect() { + async fn disconnect_errors_when_not_connected() { let _g = wallet_lock().lock().unwrap(); - connect_wallet(valid_uri()).await.unwrap(); - let info = get_wallet().await.unwrap(); - assert!(info.is_some()); + // Ensure we start clean. let _ = disconnect_wallet().await; + let err = disconnect_wallet().await.unwrap_err(); + assert!(err.to_string().contains("NoWalletConnected")); } #[tokio::test] - async fn disconnect_clears_wallet() { + async fn pay_invoice_errors_when_not_connected() { 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()); + let _ = disconnect_wallet().await; + let err = pay_invoice("lnbc1...".into()).await.unwrap_err(); + assert!(err.to_string().contains("NoWalletConnected")); } #[tokio::test] - async fn disconnect_errors_when_not_connected() { + async fn get_balance_errors_when_not_connected() { let _g = wallet_lock().lock().unwrap(); let _ = disconnect_wallet().await; - let err = disconnect_wallet().await.unwrap_err(); + let err = get_balance().await.unwrap_err(); assert!(err.to_string().contains("NoWalletConnected")); } + // Tests that require a live NWC relay are marked #[ignore]. + // Run them with: cargo test -- --ignored + #[tokio::test] - async fn pay_invoice_errors_when_not_connected() { + #[ignore = "requires a live NWC relay"] + async fn connect_stores_wallet_info() { let _g = wallet_lock().lock().unwrap(); + let uri = std::env::var("NWC_URI").expect("NWC_URI env var required"); + let info = connect_wallet(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; - 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")); + #[ignore = "requires a live NWC relay"] + async fn get_wallet_returns_info_after_connect() { + let _g = wallet_lock().lock().unwrap(); + let uri = std::env::var("NWC_URI").expect("NWC_URI env var required"); + connect_wallet(uri).await.unwrap(); + let info = get_wallet().await.unwrap(); + assert!(info.is_some()); + let _ = disconnect_wallet().await; } #[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")); + #[ignore = "requires a live NWC relay"] + async fn disconnect_clears_wallet() { + let _g = wallet_lock().lock().unwrap(); + let uri = std::env::var("NWC_URI").expect("NWC_URI env var required"); + connect_wallet(uri).await.unwrap(); + disconnect_wallet().await.unwrap(); + let info = get_wallet().await.unwrap(); + assert!(info.is_none()); } } diff --git a/rust/src/nwc/client.rs b/rust/src/nwc/client.rs index 6b0b597d..d9d03137 100644 --- a/rust/src/nwc/client.rs +++ b/rust/src/nwc/client.rs @@ -1,200 +1,393 @@ -/// 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, -} +// NWC client — real Nostr Wallet Connect (NIP-47) implementation. +// +// Uses `nostr-sdk` types for URI parsing, request/response construction, +// NIP-04 encryption, and relay communication via the SDK's `Client`. +// +// Native-only: the relay transport depends on tokio + TCP which do not +// compile to WASM. All relay-dependent items are gated behind +// `cfg(not(target_arch = "wasm32"))`. + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use std::time::Duration; + + use anyhow::{anyhow, bail, Result}; + use nostr_sdk::prelude::*; + use nostr_sdk::nips::nip04; + use nostr_sdk::nips::nip47::{ + GetBalanceResponse, MakeInvoiceRequest, + MakeInvoiceResponse, NostrWalletConnectURI, PayInvoiceRequest, + PayInvoiceResponse, Request, + }; + use nostr_sdk::Client; + + use crate::api::types::{NwcWalletInfo, PaymentResult, WalletStatus}; + + // ── Lenient NIP-47 response parsing ────────────────────────────────────── + // + // nostr-sdk's `Response::from_event` uses strict deserialization that + // rejects unknown methods (e.g. Alby's `get_budget`). We decrypt + // manually and parse just the fields we need. + + /// Parsed NIP-47 response — lenient version that tolerates unknown methods. + #[derive(Debug)] + struct Nip47Response { + #[allow(dead_code)] + result_type: String, + error: Option, + result: Option, + } -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() + #[derive(Debug, serde::Deserialize)] + struct Nip47Error { + #[allow(dead_code)] + code: String, + message: String, } -} -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"); + impl std::fmt::Display for Nip47Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} [{}]", self.message, self.code) } + } - let mut relay_urls = Vec::new(); - let mut secret_hex = String::new(); + /// Decrypt a Kind 23195 event and parse it leniently. + fn parse_nip47_response( + uri: &NostrWalletConnectURI, + event: &Event, + ) -> Result { + let json = nip04::decrypt(&uri.secret, &event.pubkey, &event.content) + .map_err(|e| anyhow!("NIP-04 decrypt failed: {e}"))?; + + #[derive(serde::Deserialize)] + struct RawResponse { + result_type: String, + error: Option, + result: Option, + } - 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(); + let raw: RawResponse = serde_json::from_str(&json) + .map_err(|e| anyhow!("NIP-47 response parse failed: {e}"))?; + + Ok(Nip47Response { + result_type: raw.result_type, + error: raw.error, + result: raw.result, + }) + } + + /// Timeout for NIP-47 request → response round-trips. + const NWC_TIMEOUT: Duration = Duration::from_secs(30); + + /// Real NWC client backed by a nostr-sdk `Client` connected to the + /// wallet's relay. + pub struct NwcClient { + client: Client, + uri: NostrWalletConnectURI, + pub info: NwcWalletInfo, + } + + impl NwcClient { + /// Parse a NWC URI, build a nostr-sdk `Client` with the NWC secret + /// key, add the relay, and connect. + pub async fn new(uri_str: &str) -> Result { + let uri = NostrWalletConnectURI::parse(uri_str) + .map_err(|e| anyhow!("InvalidNwcUri: {e}"))?; + + let keys = Keys::new(uri.secret.clone()); + let client = Client::new(keys); + + // Add all relays from the URI. + for relay_url in &uri.relays { + client + .add_relay(relay_url.clone()) + .await + .map_err(|e| anyhow!("Failed to add relay {relay_url}: {e}"))?; } + + client.connect().await; + + // connect() only spawns background tasks — wait for at least + // one relay to be actually connected before returning. + client + .wait_for_connection(Duration::from_secs(10)) + .await; + + let relay_urls: Vec = + uri.relays.iter().map(|r| r.to_string()).collect(); + + Ok(Self { + client, + info: NwcWalletInfo { + wallet_pubkey: uri.public_key.to_hex(), + wallet_name: None, + status: WalletStatus::Connecting, + balance_sats: None, + relay_urls, + last_connected_at: None, + }, + uri, + }) } - if relay_urls.is_empty() { - bail!("InvalidNwcUri: at least one relay= parameter is required"); + /// Send a NIP-47 request and await the wallet's response. + /// + /// Uses a subscribe-then-send pattern: subscribes to response events + /// BEFORE publishing the request so the response is never missed, + /// even if the wallet replies before EOSE. + async fn send_request(&self, request: Request) -> Result { + let event = request + .to_event(&self.uri) + .map_err(|e| anyhow!("Failed to build NIP-47 request event: {e}"))?; + + // 1. Start listening for Kind 23195 responses from the wallet + // BEFORE sending the request to avoid a race condition. + let mut notifications = self.client.notifications(); + + let filter = Filter::new() + .kind(Kind::WalletConnectResponse) + .author(self.uri.public_key) + .since(event.created_at); + + self.client + .subscribe(filter, None) + .await + .map_err(|e| anyhow!("Failed to subscribe for NIP-47 response: {e}"))?; + + // 2. Send the request event. + self.client + .send_event(&event) + .await + .map_err(|e| anyhow!("Failed to send NIP-47 request: {e}"))?; + + // 3. Wait for the matching response on the notification channel. + let deadline = tokio::time::Instant::now() + NWC_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!("NWC timeout: no response received from wallet within {NWC_TIMEOUT:?}"); + } + + match tokio::time::timeout(remaining, notifications.recv()).await { + Ok(Ok(RelayPoolNotification::Event { + event: resp_event, .. + })) => { + if resp_event.kind == Kind::WalletConnectResponse { + match parse_nip47_response(&self.uri, &resp_event) { + Ok(resp) => return Ok(resp), + Err(_) => continue, + } + } + } + Ok(Ok(_)) => continue, // other notification types + Ok(Err(_)) => continue, // lagged, retry + Err(_) => { + bail!("NWC timeout: no response received from wallet within {NWC_TIMEOUT:?}"); + } + } + } } - if secret_hex.len() != 64 || !secret_hex.chars().all(|c| c.is_ascii_hexdigit()) { - bail!("InvalidNwcUri: secret must be a 64-char hex string"); + /// Query wallet info (name, supported methods) via NIP-47 `get_info`. + pub async fn get_info(&mut self) -> Result { + let response = self.send_request(Request::get_info()).await?; + + if let Some(err) = response.error { + bail!("NWC get_info error: {err}"); + } + + if let Some(result) = response.result { + // GetInfoResponse.methods uses a strict Method enum that rejects + // unknown methods (e.g. Alby's "get_budget"). Parse just the + // alias field we need. + #[derive(serde::Deserialize)] + struct LenientGetInfo { + #[serde(default)] + alias: Option, + } + if let Ok(info) = serde_json::from_value::(result) { + self.info.wallet_name = info.alias; + } + } + + self.info.status = WalletStatus::Connected; + self.info.last_connected_at = Some(unix_now()); + + Ok(self.info.clone()) } - Ok(Self { - wallet_pubkey, - relay_urls, - secret_hex, - }) - } -} + /// Query the wallet balance in satoshis. + /// + /// The NIP-47 `get_balance` response returns millisatoshis; this + /// method converts to sats via floor division (`msat / 1000`). + pub async fn get_balance(&self) -> Result> { + if self.info.status != WalletStatus::Connected { + bail!("NoWalletConnected: wallet is not connected"); + } -/// 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('%')); + let response = self.send_request(Request::get_balance()).await?; + + if let Some(err) = response.error { + bail!("NWC get_balance error: {err}"); + } + + match response.result { + Some(result) => { + let bal: GetBalanceResponse = serde_json::from_value(result) + .map_err(|e| anyhow!("NWC get_balance parse error: {e}"))?; + // balance is in millisatoshis — convert to sats. + Ok(Some(bal.balance / 1000)) } - _ => { - out.push('%'); - if let Some(ch) = c1 { - out.push(ch); + None => Ok(None), + } + } + + /// Pay a BOLT-11 invoice via the connected wallet. + 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()), + }); + } + + let request = + Request::pay_invoice(PayInvoiceRequest::new(bolt11.to_string())); + let response = self.send_request(request).await; + + match response { + Ok(resp) => { + if let Some(err) = resp.error { + return Ok(PaymentResult { + success: false, + preimage: None, + error: Some(format!("{err}")), + }); } - if let Some(ch) = c2 { - out.push(ch); + match resp.result { + Some(result) => { + let pay: PayInvoiceResponse = + serde_json::from_value(result).map_err(|e| { + anyhow!("NWC pay_invoice parse error: {e}") + })?; + Ok(PaymentResult { + success: true, + preimage: Some(pay.preimage), + error: None, + }) + } + _ => Ok(PaymentResult { + success: false, + preimage: None, + error: Some("Unexpected response from wallet".into()), + }), } } + Err(e) => Ok(PaymentResult { + success: false, + preimage: None, + error: Some(e.to_string()), + }), + } + } + + /// Request the wallet to create a new Lightning invoice. + /// + /// `amount_sats` is converted to millisatoshis for the NIP-47 request. + /// Returns the BOLT-11 invoice string. + pub async fn make_invoice( + &self, + amount_sats: u64, + description: Option, + ) -> Result { + if self.info.status != WalletStatus::Connected { + bail!("NoWalletConnected: wallet is not connected"); + } + + let msats = amount_sats + .checked_mul(1000) + .ok_or_else(|| anyhow::anyhow!("Amount overflow: {amount_sats} sats exceeds maximum"))?; + + let request = Request::make_invoice(MakeInvoiceRequest { + amount: msats, + description, + description_hash: None, + expiry: None, + }); + + let response = self.send_request(request).await?; + + if let Some(err) = response.error { + bail!("NWC make_invoice error: {err}"); + } + + match response.result { + Some(result) => { + let inv: MakeInvoiceResponse = serde_json::from_value(result) + .map_err(|e| anyhow!("NWC make_invoice parse error: {e}"))?; + Ok(inv.invoice) + } + _ => bail!("Unexpected response from wallet for make_invoice"), } - } else { - out.push(c); } + + /// Disconnect the nostr-sdk client from all relays. + pub async fn disconnect(&self) { + self.client.disconnect().await; + } + } + + fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 } - out } -// ── NWC client ──────────────────────────────────────────────────────────────── +// ── Public re-exports ──────────────────────────────────────────────────────── + +#[cfg(not(target_arch = "wasm32"))] +pub use native::NwcClient; + +// ── WASM stub ──────────────────────────────────────────────────────────────── -/// In-memory NWC client holding parsed credentials and wallet state. +/// On WASM targets, NWC is not supported (nostr-sdk relay transport requires +/// tokio + TCP). This stub allows the crate to compile for web while the API +/// layer returns appropriate errors. +#[cfg(target_arch = "wasm32")] pub struct NwcClient { - pub info: NwcWalletInfo, - /// NWC client secret key (hex) — used to sign NIP-47 requests. - #[allow(dead_code)] - pub(super) secret_hex: String, + pub info: crate::api::types::NwcWalletInfo, } +#[cfg(target_arch = "wasm32")] 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(), - } + pub async fn new(_uri_str: &str) -> anyhow::Result { + anyhow::bail!("NWC is not supported on web") } - /// 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()) + pub async fn get_info(&mut self) -> anyhow::Result { + anyhow::bail!("NWC is not supported on web") } - /// 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) + pub async fn get_balance(&self) -> anyhow::Result> { + anyhow::bail!("NWC is not supported on web") } - /// 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()), - }) + pub async fn pay_invoice(&self, _bolt11: &str) -> anyhow::Result { + anyhow::bail!("NWC is not supported on web") + } + + pub async fn make_invoice( + &self, + _amount_sats: u64, + _description: Option, + ) -> anyhow::Result { + anyhow::bail!("NWC is not supported on web") } -} -fn unix_now() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64 + pub async fn disconnect(&self) {} } // ── Tests ──────────────────────────────────────────────────────────────────── @@ -203,95 +396,68 @@ fn unix_now() -> i64 { 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() - ) - } - + /// Verify mSAT → sats conversion: 1000 mSAT → 1 sat (floor division). #[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()); + fn msat_to_sats_exact() { + assert_eq!(1000_u64 / 1000, 1); } + /// Verify mSAT → sats conversion: 1500 mSAT → 1 sat (floor division). #[test] - fn parse_rejects_missing_prefix() { - let err = NwcUri::parse("nostr+connect://aaaa").unwrap_err(); - assert!(err.to_string().contains("InvalidNwcUri")); + fn msat_to_sats_floor() { + assert_eq!(1500_u64 / 1000, 1); } + /// Verify mSAT → sats conversion: 999 mSAT → 0 sat (below threshold). #[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")); + fn msat_to_sats_below_threshold() { + assert_eq!(999_u64 / 1000, 0); } - #[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")); + /// URI parsing is delegated to nostr-sdk's `NostrWalletConnectURI::parse`. + #[tokio::test] + async fn parse_rejects_invalid_uri() { + let err = NwcClient::new("not-a-valid-uri") + .await + .err() + .expect("should fail for invalid URI"); + assert!(err.to_string().contains("InvalidNwcUri")); } - #[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")); + fn test_uri() -> &'static str { + "nostr+walletconnect://0cc2b404a3ff52138e489db480048b3c096eb7d6438c872b5ecd386494b06084?relay=wss://relay.getalby.com&relay=wss://relay2.getalby.com&secret=09ef2ea6bb48dc1786529254dae8bf5f9340ec93576baea91f3bdc8f8493903a&lud16=lncurl_blighted_waffle@getalby.com" } - #[test] - fn parse_rejects_short_secret() { - let uri = format!( - "nostr+walletconnect://{}?relay=wss://r.io&secret=abc", - valid_pubkey() + #[tokio::test] + async fn connect_to_alby_relay() { + let client = NwcClient::new(test_uri()).await.unwrap(); + assert_eq!(client.info.status, crate::api::types::WalletStatus::Connecting); + assert!(!client.info.relay_urls.is_empty()); + // Verify the URI was parsed correctly. + assert_eq!( + client.info.wallet_pubkey, + "0cc2b404a3ff52138e489db480048b3c096eb7d6438c872b5ecd386494b06084" ); - let err = NwcUri::parse(&uri).unwrap_err(); - assert!(err.to_string().contains("InvalidNwcUri")); + client.disconnect().await; } #[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); + async fn get_info_from_alby() { + let mut client = NwcClient::new(test_uri()).await.unwrap(); let info = client.get_info().await.unwrap(); - assert_eq!(info.status, WalletStatus::Connected); + assert_eq!(info.status, crate::api::types::WalletStatus::Connected); assert!(info.last_connected_at.is_some()); + println!("wallet_name: {:?}", info.wallet_name); + client.disconnect().await; } #[tokio::test] - async fn pay_invoice_returns_not_implemented() { - let uri = NwcUri::parse(&valid_uri()).unwrap(); - let mut client = NwcClient::new(&uri); + async fn get_balance_from_alby() { + let mut client = NwcClient::new(test_uri()).await.unwrap(); 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")); + let balance = client.get_balance().await.unwrap(); + println!("balance_sats: {:?}", balance); + assert!(balance.is_some()); + client.disconnect().await; } }