From 9c5223f5903701f54525ea53b409b2e65f6d7aa0 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 17:42:42 -0300 Subject: [PATCH 1/7] feat(nwc): implement real NIP-47 Nostr Wallet Connect client Replace stub NWC client with real implementation using nostr-sdk's NIP-47 types and Client for relay communication: - Parse NWC URI via NostrWalletConnectURI::parse (replaces hand-rolled parser) - NwcClient connects to wallet relay, sends encrypted NIP-47 requests, and awaits Kind 23195 response events - get_info(): sends get_info request, populates wallet name from alias - get_balance(): sends get_balance request, converts mSAT to sats - pay_invoice(): sends pay_invoice request, returns preimage on success - make_invoice(): sends make_invoice request, returns bolt11 string - connect_wallet() now fetches initial balance after get_info - disconnect_wallet() cleanly disconnects the nostr-sdk Client - WASM gate: all relay-dependent code behind cfg(not(wasm32)); WASM targets get clear 'NWC not supported on web' errors - Add nip47 feature to nostr-sdk dependency in Cargo.toml - Unit tests for mSAT to sats conversion; live relay tests marked ignore --- rust/Cargo.lock | 12 + rust/Cargo.toml | 2 +- rust/src/api/nwc.rs | 144 +++++------ rust/src/nwc/client.rs | 530 +++++++++++++++++++++++------------------ 4 files changed, 385 insertions(+), 303 deletions(-) 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..5f9de427 100644 --- a/rust/src/api/nwc.rs +++ b/rust/src/api/nwc.rs @@ -4,15 +4,13 @@ /// `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}; +use crate::nwc::client::NwcClient; // ── Wallet store ────────────────────────────────────────────────────────────── @@ -55,20 +53,30 @@ 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 mut guard = store.client.write().await; let had = guard.is_some(); + // Disconnect the old client if present. + if let Some(old) = guard.take() { + old.disconnect().await; + } *guard = Some(client); had }; @@ -88,10 +96,10 @@ 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"); + match guard.take() { + Some(old) => old.disconnect().await, + None => bail!("NoWalletConnected: no wallet is currently connected"), } - *guard = None; } store.notify(None); Ok(()) @@ -103,21 +111,15 @@ 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 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) + let guard = wallet_store().client.read().await; + let client = guard + .as_ref() + .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))?; + client.get_balance().await } /// Pay a BOLT-11 invoice via the connected NWC wallet. @@ -127,22 +129,17 @@ 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 { + + let guard = wallet_store().client.read().await; + let client = guard + .as_ref() + .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))?; + + if client.info.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,6 +177,7 @@ pub fn on_wallet_status_changed() -> WalletStatusStream { // ── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] +#[allow(clippy::await_holding_lock)] mod tests { use super::*; use std::sync::{Mutex, OnceLock as StdOnceLock}; @@ -191,67 +189,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..69fad1a8 100644 --- a/rust/src/nwc/client.rs +++ b/rust/src/nwc/client.rs @@ -1,200 +1,292 @@ -/// 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() +// 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::nip47::{ + GetBalanceResponse, GetInfoResponse, MakeInvoiceRequest, + MakeInvoiceResponse, NostrWalletConnectURI, PayInvoiceRequest, + PayInvoiceResponse, Request, Response, ResponseResult, + }; + use nostr_sdk::Client; + + use crate::api::types::{NwcWalletInfo, PaymentResult, WalletStatus}; + + /// 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 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 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}"))?; + } - let mut relay_urls = Vec::new(); - let mut secret_hex = String::new(); + client.connect().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, + }) + } - 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://"); + /// Send a NIP-47 request and await the wallet's response. + 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}"))?; + + self.client + .send_event(&event) + .await + .map_err(|e| anyhow!("Failed to send NIP-47 request: {e}"))?; + + // Subscribe to the response: Kind 23195 from the wallet pubkey, + // created after the request event's timestamp. + let filter = Filter::new() + .kind(Kind::WalletConnectResponse) + .author(self.uri.public_key) + .since(event.created_at); + + let events = self + .client + .fetch_events(filter, NWC_TIMEOUT) + .await + .map_err(|e| anyhow!("Failed to fetch NIP-47 response: {e}"))?; + + // Find the response that matches our request (most recent first). + for resp_event in events.into_iter() { + match Response::from_event(&self.uri, &resp_event) { + Ok(resp) => return Ok(resp), + Err(_) => continue, // not our response, try next } - 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"); + 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(ResponseResult::GetInfo(GetInfoResponse { alias, .. })) = + response.result + { + self.info.wallet_name = 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(ResponseResult::GetBalance(GetBalanceResponse { balance })) => { + // balance is in millisatoshis — convert to sats. + Ok(Some(balance / 1000)) } - _ => { - out.push('%'); - if let Some(ch) = c1 { - out.push(ch); + _ => 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(ResponseResult::PayInvoice(PayInvoiceResponse { + preimage, + .. + })) => Ok(PaymentResult { + success: true, + preimage: Some(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 request = Request::make_invoice(MakeInvoiceRequest { + amount: amount_sats * 1000, // convert sats → 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(ResponseResult::MakeInvoice(MakeInvoiceResponse { + invoice, + .. + })) => Ok(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; -/// In-memory NWC client holding parsed credentials and wallet state. +// ── WASM stub ──────────────────────────────────────────────────────────────── + +/// 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 +295,65 @@ 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); } + /// URI parsing is delegated to nostr-sdk's `NostrWalletConnectURI::parse`. #[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")); + fn parse_rejects_invalid_uri() { + #[cfg(not(target_arch = "wasm32"))] + { + let result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(NwcClient::new("not-a-valid-uri")); + let err = result.err().expect("should fail for invalid URI"); + assert!(err.to_string().contains("InvalidNwcUri")); + } } - #[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")); + /// Tests that previously checked for NotImplemented now require a live + /// NWC relay and are therefore marked #[ignore]. + #[tokio::test] + #[ignore = "requires a live NWC relay"] + async fn get_info_with_live_relay() { + // To run: cargo test -- --ignored get_info_with_live_relay + // Set NWC_URI env var to a real NWC URI. + let uri = std::env::var("NWC_URI").expect("NWC_URI env var required"); + let mut client = NwcClient::new(&uri).await.unwrap(); + let info = client.get_info().await.unwrap(); + assert_eq!(info.status, crate::api::types::WalletStatus::Connected); } #[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()); + #[ignore = "requires a live NWC relay"] + async fn pay_invoice_with_live_relay() { + let uri = std::env::var("NWC_URI").expect("NWC_URI env var required"); + let mut client = NwcClient::new(&uri).await.unwrap(); + client.get_info().await.unwrap(); + let _result = client.pay_invoice("lnbc1...").await.unwrap(); } #[tokio::test] - async fn pay_invoice_returns_not_implemented() { - let uri = NwcUri::parse(&valid_uri()).unwrap(); - let mut client = NwcClient::new(&uri); + #[ignore = "requires a live NWC relay"] + async fn get_balance_with_live_relay() { + let uri = std::env::var("NWC_URI").expect("NWC_URI env var required"); + let mut client = NwcClient::new(&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(); } } From 0a43de7bbc47000cfa35b6b7f6cc566aeeef7af5 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 17:58:35 -0300 Subject: [PATCH 2/7] fix: address review findings in NWC implementation - Store NwcClient in Arc so RwLock is released before network I/O: get_balance() and pay_invoice() clone the Arc and drop the guard before awaiting; connect/disconnect also release locks before await - Remove redundant WalletStatus check in pay_invoice (NwcClient already validates connection status internally) - Guard sats-to-msats conversion in make_invoice with checked_mul to prevent overflow on large amounts - Convert parse_rejects_invalid_uri test to #[tokio::test] for consistency with other async tests in the module --- rust/src/api/nwc.rs | 64 +++++++++++++++++++++++------------------- rust/src/nwc/client.rs | 23 +++++++-------- 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/rust/src/api/nwc.rs b/rust/src/api/nwc.rs index 5f9de427..b9f9431a 100644 --- a/rust/src/api/nwc.rs +++ b/rust/src/api/nwc.rs @@ -5,17 +5,17 @@ /// /// The underlying NIP-47 protocol exchange is handled by [`crate::nwc::client`]. 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::api::types::{NwcWalletInfo, PaymentResult}; use crate::nwc::client::NwcClient; // ── Wallet store ────────────────────────────────────────────────────────────── struct WalletStore { - client: RwLock>, + client: RwLock>>, status_tx: broadcast::Sender>, } @@ -70,16 +70,17 @@ pub async fn connect_wallet(nwc_uri: String) -> Result { }; let store = wallet_store(); - let had_existing = { + let (had_existing, old_client) = { let mut guard = store.client.write().await; - let had = guard.is_some(); - // Disconnect the old client if present. - if let Some(old) = guard.take() { - old.disconnect().await; - } - *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 { @@ -94,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; - match guard.take() { - Some(old) => old.disconnect().await, - None => bail!("NoWalletConnected: no wallet is currently connected"), - } - } + guard + .take() + .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))? + }; + old.disconnect().await; store.notify(None); Ok(()) } @@ -115,10 +116,14 @@ pub async fn get_wallet() -> Result> { /// /// **Errors**: `NoWalletConnected`, `WalletError`. pub async fn get_balance() -> Result> { - let guard = wallet_store().client.read().await; - let client = guard - .as_ref() - .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))?; + let client = { + let guard = wallet_store().client.read().await; + Arc::clone( + guard + .as_ref() + .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))?, + ) + }; client.get_balance().await } @@ -130,14 +135,14 @@ pub async fn pay_invoice(bolt11: String) -> Result { bail!("InvoiceInvalid: bolt11 must not be empty"); } - let guard = wallet_store().client.read().await; - let client = guard - .as_ref() - .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))?; - - if client.info.status != WalletStatus::Connected { - bail!("NoWalletConnected: wallet is not connected"); - } + let client = { + let guard = wallet_store().client.read().await; + Arc::clone( + guard + .as_ref() + .ok_or_else(|| anyhow!("NoWalletConnected: no wallet is currently connected"))?, + ) + }; client.pay_invoice(&bolt11).await } @@ -180,6 +185,7 @@ pub fn on_wallet_status_changed() -> WalletStatusStream { #[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 diff --git a/rust/src/nwc/client.rs b/rust/src/nwc/client.rs index 69fad1a8..92d92c23 100644 --- a/rust/src/nwc/client.rs +++ b/rust/src/nwc/client.rs @@ -209,8 +209,12 @@ mod native { 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: amount_sats * 1000, // convert sats → msats + amount: msats, description, description_hash: None, expiry: None, @@ -314,16 +318,13 @@ mod tests { } /// URI parsing is delegated to nostr-sdk's `NostrWalletConnectURI::parse`. - #[test] - fn parse_rejects_invalid_uri() { - #[cfg(not(target_arch = "wasm32"))] - { - let result = tokio::runtime::Runtime::new() - .unwrap() - .block_on(NwcClient::new("not-a-valid-uri")); - let err = result.err().expect("should fail for invalid URI"); - assert!(err.to_string().contains("InvalidNwcUri")); - } + #[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")); } /// Tests that previously checked for NotImplemented now require a live From 8fafc28958143c385fc8db9d9c4bd3555aad8742 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 18:09:05 -0300 Subject: [PATCH 3/7] fix(nwc): use subscribe-then-send pattern for NIP-47 requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch_events exits on EOSE (end of stored events) which misses the wallet's response — it arrives as a NEW event after EOSE. Fix by: 1. Subscribe to Kind 23195 response events via notifications channel BEFORE sending the request (eliminates race condition) 2. Send the Kind 23194 request event 3. Listen on the notification channel for the matching response This fixes 'Connection failed' errors when connecting real NWC wallets like Alby, where the response arrives after the relay's EOSE marker. --- rust/src/nwc/client.rs | 57 +++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/rust/src/nwc/client.rs b/rust/src/nwc/client.rs index 92d92c23..2d734042 100644 --- a/rust/src/nwc/client.rs +++ b/rust/src/nwc/client.rs @@ -71,38 +71,61 @@ mod native { } /// 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}"))?; - self.client - .send_event(&event) - .await - .map_err(|e| anyhow!("Failed to send NIP-47 request: {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(); - // Subscribe to the response: Kind 23195 from the wallet pubkey, - // created after the request event's timestamp. let filter = Filter::new() .kind(Kind::WalletConnectResponse) .author(self.uri.public_key) .since(event.created_at); - let events = self - .client - .fetch_events(filter, NWC_TIMEOUT) + 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 fetch NIP-47 response: {e}"))?; + .map_err(|e| anyhow!("Failed to send NIP-47 request: {e}"))?; - // Find the response that matches our request (most recent first). - for resp_event in events.into_iter() { - match Response::from_event(&self.uri, &resp_event) { - Ok(resp) => return Ok(resp), - Err(_) => continue, // not our response, try next + // 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:?}"); } - } - 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 Response::from_event(&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:?}"); + } + } + } } /// Query wallet info (name, supported methods) via NIP-47 `get_info`. From b8a5aa3accf6333db75e458cc9b9ffee782b8c45 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 18:19:49 -0300 Subject: [PATCH 4/7] fix(nwc): lenient NIP-47 parsing + wait for relay connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes for NWC connection failure with Alby: 1. connect() only spawns background tasks — the relay wasn't ready when send_event was called. Fix: call wait_for_connection(10s) after connect() to block until at least one relay is connected. 2. nostr-sdk's Response::from_event uses strict deserialization that rejects unknown NIP-47 methods (Alby returns 'get_budget' which isn't in the spec). Fix: implement lenient parsing — decrypt NIP-04 manually and parse the JSON with serde_json, extracting only the fields we need. GetInfoResponse also parsed leniently since its methods field has the same strict enum issue. Add live integration tests against Alby relay: - connect_to_alby_relay: verifies URI parsing and relay connection - get_info_from_alby: verifies get_info round-trip (wallet name) - get_balance_from_alby: verifies balance fetch (mSAT to sats) --- rust/src/nwc/client.rs | 168 ++++++++++++++++++++++++++++++----------- 1 file changed, 124 insertions(+), 44 deletions(-) diff --git a/rust/src/nwc/client.rs b/rust/src/nwc/client.rs index 2d734042..d9d03137 100644 --- a/rust/src/nwc/client.rs +++ b/rust/src/nwc/client.rs @@ -13,15 +13,69 @@ mod native { use anyhow::{anyhow, bail, Result}; use nostr_sdk::prelude::*; + use nostr_sdk::nips::nip04; use nostr_sdk::nips::nip47::{ - GetBalanceResponse, GetInfoResponse, MakeInvoiceRequest, + GetBalanceResponse, MakeInvoiceRequest, MakeInvoiceResponse, NostrWalletConnectURI, PayInvoiceRequest, - PayInvoiceResponse, Request, Response, ResponseResult, + 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, + } + + #[derive(Debug, serde::Deserialize)] + struct Nip47Error { + #[allow(dead_code)] + code: String, + message: String, + } + + impl std::fmt::Display for Nip47Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} [{}]", self.message, self.code) + } + } + + /// 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, + } + + 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); @@ -53,6 +107,12 @@ mod native { 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(); @@ -75,7 +135,7 @@ mod native { /// 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 { + 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}"))?; @@ -113,7 +173,7 @@ mod native { event: resp_event, .. })) => { if resp_event.kind == Kind::WalletConnectResponse { - match Response::from_event(&self.uri, &resp_event) { + match parse_nip47_response(&self.uri, &resp_event) { Ok(resp) => return Ok(resp), Err(_) => continue, } @@ -136,10 +196,18 @@ mod native { bail!("NWC get_info error: {err}"); } - if let Some(ResponseResult::GetInfo(GetInfoResponse { alias, .. })) = - response.result - { - self.info.wallet_name = alias; + 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; @@ -164,11 +232,13 @@ mod native { } match response.result { - Some(ResponseResult::GetBalance(GetBalanceResponse { balance })) => { + 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(balance / 1000)) + Ok(Some(bal.balance / 1000)) } - _ => Ok(None), + None => Ok(None), } } @@ -196,14 +266,17 @@ mod native { }); } match resp.result { - Some(ResponseResult::PayInvoice(PayInvoiceResponse { - preimage, - .. - })) => Ok(PaymentResult { - success: true, - preimage: Some(preimage), - error: None, - }), + 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, @@ -250,10 +323,11 @@ mod native { } match response.result { - Some(ResponseResult::MakeInvoice(MakeInvoiceResponse { - invoice, - .. - })) => Ok(invoice), + 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"), } } @@ -350,34 +424,40 @@ mod tests { assert!(err.to_string().contains("InvalidNwcUri")); } - /// Tests that previously checked for NotImplemented now require a live - /// NWC relay and are therefore marked #[ignore]. + 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" + } + #[tokio::test] - #[ignore = "requires a live NWC relay"] - async fn get_info_with_live_relay() { - // To run: cargo test -- --ignored get_info_with_live_relay - // Set NWC_URI env var to a real NWC URI. - let uri = std::env::var("NWC_URI").expect("NWC_URI env var required"); - let mut client = NwcClient::new(&uri).await.unwrap(); - let info = client.get_info().await.unwrap(); - assert_eq!(info.status, crate::api::types::WalletStatus::Connected); + 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" + ); + client.disconnect().await; } #[tokio::test] - #[ignore = "requires a live NWC relay"] - async fn pay_invoice_with_live_relay() { - let uri = std::env::var("NWC_URI").expect("NWC_URI env var required"); - let mut client = NwcClient::new(&uri).await.unwrap(); - client.get_info().await.unwrap(); - let _result = client.pay_invoice("lnbc1...").await.unwrap(); + 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, crate::api::types::WalletStatus::Connected); + assert!(info.last_connected_at.is_some()); + println!("wallet_name: {:?}", info.wallet_name); + client.disconnect().await; } #[tokio::test] - #[ignore = "requires a live NWC relay"] - async fn get_balance_with_live_relay() { - let uri = std::env::var("NWC_URI").expect("NWC_URI env var required"); - let mut client = NwcClient::new(&uri).await.unwrap(); + async fn get_balance_from_alby() { + let mut client = NwcClient::new(test_uri()).await.unwrap(); client.get_info().await.unwrap(); - let _balance = client.get_balance().await.unwrap(); + let balance = client.get_balance().await.unwrap(); + println!("balance_sats: {:?}", balance); + assert!(balance.is_some()); + client.disconnect().await; } } From f2c543b16f25cb952508bb4800a4f585c2aee14d Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 18:22:34 -0300 Subject: [PATCH 5/7] fix(ui): add back button to wallet configuration screen connect_wallet_screen uses context.go() which replaces the nav stack, so the default AppBar back button never appears. Add an explicit leading back button that pops if possible, otherwise navigates to settings. --- .../settings/screens/wallet_settings_screen.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 From 042a3d01fc19f194281e238741a2ed933ef80175 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 18:24:33 -0300 Subject: [PATCH 6/7] fix(ui): add back button to settings screen --- lib/features/settings/screens/settings_screen.dart | 5 +++++ 1 file changed, 5 insertions(+) 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), From 823ed2a7a3b94c43ac5dc204bba71025669d73a1 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 3 Apr 2026 18:28:42 -0300 Subject: [PATCH 7/7] feat(nwc): persist NWC URI across app restarts Save the NWC URI to SharedPreferences on connect, remove on disconnect. On app startup, if a saved URI exists, reconnect in the background. - NwcNotifier now takes SharedPreferences and persists the URI via kNwcUriKey on setConnected, clears it on setDisconnected - main.dart creates ProviderContainer explicitly, overrides nwcProvider with prefs, and calls _restoreNwcConnection before runApp - connect_wallet_screen passes the URI string to setConnected --- .../settings/providers/nwc_provider.dart | 22 +++++++++-- .../screens/connect_wallet_screen.dart | 1 + lib/main.dart | 37 ++++++++++++++++++- 3 files changed, 56 insertions(+), 4 deletions(-) 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/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;