Skip to main content

cdk_ffi/
nwc.rs

1//! FFI bindings for the Nostr Wallet Connect (NIP-47) wallet service.
2//!
3//! Exposes an [`NwcService`] object that turns a CDK [`Wallet`] into a NIP-47
4//! wallet service: it generates a `nostr+walletconnect://` connection URI to
5//! hand to a Nostr app, then listens on the configured relays and answers the
6//! supported commands (`get_info`, `get_balance`, `make_invoice`,
7//! `pay_invoice`, `lookup_invoice`, `list_transactions`) using the wallet.
8
9use std::sync::{Arc, Mutex};
10
11use cdk::wallet::WalletNwcHandler;
12use cdk_nwc::{NwcService as CdkNwcService, NwcServiceConfig};
13use nostr_sdk::{Keys, RelayUrl, SecretKey};
14use tokio::task::JoinHandle;
15use tokio_util::sync::CancellationToken;
16
17use crate::error::FfiError;
18use crate::wallet::Wallet;
19
20/// A NIP-47 Nostr Wallet Connect wallet service bound to a CDK wallet.
21///
22/// Create one with [`NwcService::create`] (new connection) or
23/// [`NwcService::restore`] (existing connection from a persisted client
24/// secret), call [`NwcService::connection_uri`] to obtain the URI for the
25/// Nostr app, then [`NwcService::start`] to begin servicing requests.
26#[derive(uniffi::Object)]
27pub struct NwcService {
28    service: CdkNwcService,
29    handler: Arc<WalletNwcHandler>,
30    task: Mutex<Option<(JoinHandle<()>, CancellationToken)>>,
31}
32
33impl NwcService {
34    fn clear_finished_task(task: &mut Option<(JoinHandle<()>, CancellationToken)>) {
35        if task
36            .as_ref()
37            .is_some_and(|(handle, _)| handle.is_finished())
38        {
39            drop(task.take());
40        }
41    }
42
43    /// Shared construction logic for [`Self::create`] and [`Self::restore`].
44    fn build(
45        wallet: &Arc<Wallet>,
46        relays: Vec<String>,
47        service_keys: Keys,
48        client_secret: SecretKey,
49        max_payment_msat: Option<u64>,
50    ) -> Result<Self, FfiError> {
51        if relays.is_empty() {
52            return Err(FfiError::internal("at least one relay is required"));
53        }
54
55        let relays = relays
56            .iter()
57            .map(|r| {
58                RelayUrl::parse(r)
59                    .map_err(|e| FfiError::internal(format!("invalid relay {r}: {e}")))
60            })
61            .collect::<Result<Vec<_>, _>>()?;
62
63        let handler = Arc::new(WalletNwcHandler::new(
64            wallet.inner().clone(),
65            max_payment_msat,
66        ));
67
68        let service = CdkNwcService::new(NwcServiceConfig {
69            service_keys,
70            client_secret,
71            relays,
72            lud16: None,
73        })
74        .map_err(|e| FfiError::internal(e.to_string()))?;
75
76        Ok(Self {
77            service,
78            handler,
79            task: Mutex::new(None),
80        })
81    }
82}
83
84impl Drop for NwcService {
85    fn drop(&mut self) {
86        let Ok(mut guard) = self.task.lock() else {
87            return;
88        };
89
90        if let Some((handle, cancel)) = guard.take() {
91            cancel.cancel();
92            handle.abort();
93        }
94    }
95}
96
97#[uniffi::export(async_runtime = "tokio")]
98impl NwcService {
99    /// Create a new wallet service with a freshly generated client connection.
100    ///
101    /// # Arguments
102    ///
103    /// * `wallet` - The CDK wallet that backs the service.
104    /// * `relays` - Relay URLs the service connects to and listens on.
105    /// * `service_secret_key` - Secret key of the wallet service (the signer).
106    ///   Accepts hex or bech32 `nsec`. Derive a stable one from the wallet seed
107    ///   with [`nwc_derive_service_secret_key_from_seed`].
108    /// * `max_payment_msat` - Optional cap (in millisatoshis) on any single
109    ///   `pay_invoice` request.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if a key or relay URL is invalid, or no relays are given.
114    #[uniffi::constructor]
115    pub fn create(
116        wallet: Arc<Wallet>,
117        relays: Vec<String>,
118        service_secret_key: String,
119        max_payment_msat: Option<u64>,
120    ) -> Result<Self, FfiError> {
121        let service_keys = parse_keys(&service_secret_key)?;
122        let client_secret = SecretKey::generate();
123        Self::build(
124            &wallet,
125            relays,
126            service_keys,
127            client_secret,
128            max_payment_msat,
129        )
130    }
131
132    /// Restore a wallet service for an existing connection.
133    ///
134    /// Use this to rebuild a service after a restart from a persisted client
135    /// secret, so the previously issued connection URI keeps working.
136    ///
137    /// # Arguments
138    ///
139    /// * `client_secret_key` - The client secret from the original connection
140    ///   URI (hex or `nsec`).
141    ///
142    /// See [`Self::create`] for the other arguments.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if a key or relay URL is invalid, or no relays are given.
147    #[uniffi::constructor]
148    pub fn restore(
149        wallet: Arc<Wallet>,
150        relays: Vec<String>,
151        service_secret_key: String,
152        client_secret_key: String,
153        max_payment_msat: Option<u64>,
154    ) -> Result<Self, FfiError> {
155        let service_keys = parse_keys(&service_secret_key)?;
156        let client_secret = parse_secret_key(&client_secret_key)?;
157        Self::build(
158            &wallet,
159            relays,
160            service_keys,
161            client_secret,
162            max_payment_msat,
163        )
164    }
165
166    /// The `nostr+walletconnect://` connection URI to hand to the Nostr app.
167    pub fn connection_uri(&self) -> String {
168        self.service.connection_uri().to_string()
169    }
170
171    /// Hex-encoded public key of the wallet service (advertised in the URI).
172    pub fn service_pubkey(&self) -> String {
173        self.service.service_pubkey().to_hex()
174    }
175
176    /// Hex-encoded public key of the authorized client.
177    pub fn client_pubkey(&self) -> String {
178        self.service.client_pubkey().to_hex()
179    }
180
181    /// Start servicing requests in the background.
182    ///
183    /// Connects to the relays, publishes the info event, and begins answering
184    /// commands. Returns immediately; the service runs until [`Self::stop`] is
185    /// called. Per-request failures are answered with NIP-47 error responses
186    /// and logged rather than surfaced here.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the service is already running.
191    // `async` is required so uniffi drives this on the tokio runtime, which
192    // `tokio::spawn` needs; the body itself does not await.
193    #[allow(clippy::unused_async)]
194    pub async fn start(&self) -> Result<(), FfiError> {
195        let mut guard = self
196            .task
197            .lock()
198            .map_err(|_| FfiError::internal("nwc service lock poisoned"))?;
199
200        Self::clear_finished_task(&mut guard);
201
202        if guard.is_some() {
203            return Err(FfiError::internal("nwc service is already running"));
204        }
205
206        let cancel = CancellationToken::new();
207        let service = self.service.clone();
208        let handler = self.handler.clone();
209        let run_cancel = cancel.clone();
210
211        let handle = tokio::spawn(async move {
212            if let Err(e) = service.run(handler, run_cancel).await {
213                tracing::error!("NWC service stopped with error: {e}");
214            }
215        });
216
217        *guard = Some((handle, cancel));
218        Ok(())
219    }
220
221    /// Stop the background service if it is running.
222    pub async fn stop(&self) -> Result<(), FfiError> {
223        let task = {
224            let mut guard = self
225                .task
226                .lock()
227                .map_err(|_| FfiError::internal("nwc service lock poisoned"))?;
228            guard.take()
229        };
230
231        if let Some((handle, cancel)) = task {
232            cancel.cancel();
233            handle.abort();
234            let _ = handle.await;
235        }
236
237        Ok(())
238    }
239
240    /// Whether the background service is currently running.
241    pub fn is_running(&self) -> bool {
242        let Ok(mut guard) = self.task.lock() else {
243            return false;
244        };
245
246        Self::clear_finished_task(&mut guard);
247        guard.is_some()
248    }
249}
250
251/// Derive the NWC wallet-service secret key from a wallet seed.
252///
253/// Returns a hex-encoded secret key for use as `service_secret_key`. Deriving
254/// from the seed keeps the connection URI stable across restarts. Uses the
255/// NIP-06 path `m/44'/1237'/1'/0/0`, distinct from the npub.cash key.
256///
257/// # Errors
258///
259/// Returns an error if the seed is shorter than 64 bytes or derivation fails.
260#[uniffi::export]
261pub fn nwc_derive_service_secret_key_from_seed(seed: Vec<u8>) -> Result<String, FfiError> {
262    if seed.len() < 64 {
263        return Err(FfiError::internal("Seed must be at least 64 bytes"));
264    }
265
266    let seed: [u8; 64] = seed[..64]
267        .try_into()
268        .map_err(|_| FfiError::internal("Failed to read wallet seed bytes"))?;
269
270    let secret_key = cdk::wallet::derive_nwc_secret_key_from_seed(&seed)
271        .map_err(|e| FfiError::internal(format!("Failed to derive secret key: {e}")))?;
272
273    Ok(secret_key.to_secret_hex())
274}
275
276/// Get the hex-encoded public key for a Nostr secret key (hex or `nsec`).
277///
278/// # Errors
279///
280/// Returns an error if the secret key is invalid.
281#[uniffi::export]
282pub fn nwc_get_pubkey(nostr_secret_key: String) -> Result<String, FfiError> {
283    Ok(parse_keys(&nostr_secret_key)?.public_key().to_hex())
284}
285
286/// Parse a Nostr secret key (hex or bech32 `nsec`) into [`Keys`].
287fn parse_keys(key: &str) -> Result<Keys, FfiError> {
288    Ok(Keys::new(parse_secret_key(key)?))
289}
290
291/// Parse a Nostr secret key from either hex or bech32 `nsec`.
292fn parse_secret_key(key: &str) -> Result<SecretKey, FfiError> {
293    SecretKey::parse(key).map_err(|e| FfiError::internal(format!("invalid secret key: {e}")))
294}