Skip to main content

nula_core/nips/
nip47.rs

1//! [NIP-47] Nostr Wallet Connect (NWC).
2//!
3//! NWC stitches a Nostr **client** to a remote Lightning **wallet
4//! service** through end-to-end-encrypted direct messages over a
5//! relay. Five event kinds carry the protocol:
6//!
7//! | Kind   | Direction              | Purpose                                     |
8//! |--------|------------------------|---------------------------------------------|
9//! | 13194  | wallet → relay (replaceable) | Capability advert ([`InfoEvent`])        |
10//! | 23194  | client → wallet         | Request envelope ([`Request`])              |
11//! | 23195  | wallet → client         | Response envelope ([`Response`])            |
12//! | 23197  | wallet → client         | NIP-44 notification ([`Notification`])      |
13//! | 23196  | wallet → client         | Legacy NIP-04 notification (deprecated)     |
14//!
15//! The client gets a connection URI shaped
16//! `nostr+walletconnect://<wallet_pubkey>?relay=…&secret=…&lud16=…`
17//! ([`ConnectionUri`]) — one URI per (client, wallet) pair, with the
18//! `secret` acting as the client-side signing key for that
19//! conversation. Body content is encrypted with NIP-44 v2 by
20//! default and falls back to NIP-04 only for legacy peers
21//! ([`Encryption`]).
22//!
23//! # What this module ships
24//!
25//! - [`ConnectionUri`] — a strict URI parser/encoder backed by the
26//!   `url` crate's query-string utility. The wallet pubkey, relay
27//!   list, secret, and optional `lud16` round-trip cleanly.
28//! - [`InfoEvent`] — typed reader / builder for the `kind: 13194`
29//!   capability advert (`content` is the space-separated method
30//!   list, `notifications` and `encryption` tags carry the
31//!   capability sets).
32//! - [`Encryption::negotiate`] — the §"Encryption" handshake:
33//!   absent tag → NIP-04, prefer NIP-44 v2 when both sides support
34//!   it.
35//! - [`Request`] / [`Response`] / [`Notification`] — JSON-RPCish
36//!   payload structs (`method` / `result_type` /
37//!   `notification_type`, `params`, `result`, `error`) with
38//!   `serde_json::Value` payloads so every method spec'd today
39//!   (and any added tomorrow) round-trips without a per-method
40//!   patch.
41//! - [`ErrorCode`] — every spec'd error code as a typed variant
42//!   plus `Other(String)` for forward compatibility.
43//! - [`EventBuilder::nwc_info`] / [`EventBuilder::nwc_request`] /
44//!   [`EventBuilder::nwc_response`] / [`EventBuilder::nwc_notification`]
45//!   plus the matching [`decrypt_request`] / [`decrypt_response`] /
46//!   [`decrypt_notification`] readers — the end-to-end happy path:
47//!   build a signed encrypted event, parse one back, all behind the
48//!   `nip44` feature gate so the build stays fast for callers who
49//!   don't need NWC at all.
50//!
51//! What the module **does not** do (yet): per-method typed
52//! payloads (`PayInvoice`, `MakeInvoice`, …). Those are pure
53//! `serde_json` structs and can be layered on top without touching
54//! the envelope code.
55//!
56//! [NIP-47]: https://github.com/nostr-protocol/nips/blob/master/47.md
57
58use std::collections::HashSet;
59use std::fmt;
60
61use serde::{Deserialize, Serialize};
62use thiserror::Error;
63use url::form_urlencoded;
64
65use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind};
66use crate::key::{PublicKey, PublicKeyError, SecretKey, SecretKeyError};
67#[cfg(feature = "nip04")]
68use crate::nips::nip04;
69#[cfg(feature = "nip44")]
70use crate::nips::nip44;
71use crate::types::{RelayUrl, RelayUrlError, Timestamp};
72
73/// `kind: 13194` — info event.
74pub const KIND_INFO: Kind = Kind::WALLET_CONNECT_INFO;
75/// `kind: 23194` — request.
76pub const KIND_REQUEST: Kind = Kind::WALLET_CONNECT_REQUEST;
77/// `kind: 23195` — response.
78pub const KIND_RESPONSE: Kind = Kind::WALLET_CONNECT_RESPONSE;
79/// `kind: 23197` — NIP-44 notification.
80pub const KIND_NOTIFICATION: Kind = Kind::WALLET_CONNECT_NOTIFICATION;
81/// `kind: 23196` — legacy NIP-04 notification.
82pub const KIND_NOTIFICATION_LEGACY: Kind = Kind::WALLET_CONNECT_NOTIFICATION_LEGACY;
83
84/// URI scheme prefix used by `ConnectionUri`.
85pub const URI_SCHEME: &str = "nostr+walletconnect://";
86/// `encryption` tag head.
87pub const ENCRYPTION_TAG: &str = "encryption";
88/// `notifications` tag head.
89pub const NOTIFICATIONS_TAG: &str = "notifications";
90
91/// NIP-47 §"Encryption" — wire token of an encryption scheme.
92pub mod encryption_tokens {
93    /// `nip44_v2`.
94    pub const NIP44_V2: &str = "nip44_v2";
95    /// `nip04`.
96    pub const NIP04: &str = "nip04";
97}
98
99/// Encryption scheme negotiated for a given conversation.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub enum Encryption {
102    /// NIP-44 v2 — required by spec.
103    Nip44V2,
104    /// NIP-04 — deprecated; only used when the wallet's `info`
105    /// event omits the `encryption` tag entirely or explicitly
106    /// advertises `nip04`.
107    Nip04,
108}
109
110impl Encryption {
111    /// Wire token used in the `encryption` tag.
112    #[must_use]
113    pub const fn as_token(self) -> &'static str {
114        match self {
115            Self::Nip44V2 => encryption_tokens::NIP44_V2,
116            Self::Nip04 => encryption_tokens::NIP04,
117        }
118    }
119
120    /// Parse a wire token.
121    ///
122    /// # Errors
123    ///
124    /// [`NwcError::UnknownEncryption`] for any other value.
125    pub fn parse(token: &str) -> Result<Self, NwcError> {
126        match token {
127            encryption_tokens::NIP44_V2 => Ok(Self::Nip44V2),
128            encryption_tokens::NIP04 => Ok(Self::Nip04),
129            other => Err(NwcError::UnknownEncryption(other.to_owned())),
130        }
131    }
132
133    /// NIP-47 §"Encryption" negotiation:
134    ///
135    /// - If `wallet_supported` is empty (i.e. the wallet's info
136    ///   event omitted the `encryption` tag), fall back to NIP-04.
137    /// - Otherwise, prefer NIP-44 v2 when both sides accept it,
138    ///   else use NIP-04 if both accept it, else return
139    ///   [`NwcError::EncryptionNotNegotiable`].
140    ///
141    /// # Errors
142    ///
143    /// See above.
144    pub fn negotiate(
145        wallet_supported: &[Self],
146        client_supported: &[Self],
147    ) -> Result<Self, NwcError> {
148        if wallet_supported.is_empty() {
149            return Ok(Self::Nip04);
150        }
151        let wallet: HashSet<Self> = wallet_supported.iter().copied().collect();
152        let client: HashSet<Self> = client_supported.iter().copied().collect();
153        if wallet.contains(&Self::Nip44V2) && client.contains(&Self::Nip44V2) {
154            Ok(Self::Nip44V2)
155        } else if wallet.contains(&Self::Nip04) && client.contains(&Self::Nip04) {
156            Ok(Self::Nip04)
157        } else {
158            Err(NwcError::EncryptionNotNegotiable)
159        }
160    }
161}
162
163impl fmt::Display for Encryption {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.write_str(self.as_token())
166    }
167}
168
169/// Parsed `nostr+walletconnect://…` URI.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ConnectionUri {
172    /// Wallet service public key (host part of the URI).
173    pub wallet_pubkey: PublicKey,
174    /// One or more `relay` query parameters.
175    pub relays: Vec<RelayUrl>,
176    /// `secret` query parameter — the *client*'s 32-byte signing
177    /// key for this conversation.
178    pub secret: SecretKey,
179    /// Optional `lud16` lightning address.
180    pub lud16: Option<String>,
181}
182
183impl ConnectionUri {
184    /// Parse a `nostr+walletconnect://…` URI.
185    ///
186    /// # Errors
187    ///
188    /// - [`NwcError::UriBadScheme`] if the prefix is wrong.
189    /// - [`NwcError::UriMissingPubkey`] when the host portion is
190    ///   absent.
191    /// - [`NwcError::UriMissingRelay`] when no `relay` parameter
192    ///   was supplied (spec marks it as required).
193    /// - [`NwcError::UriMissingSecret`] when no `secret` parameter
194    ///   was supplied.
195    /// - Forwarded parse errors for individual fields.
196    pub fn parse(input: &str) -> Result<Self, NwcError> {
197        let rest = input
198            .strip_prefix(URI_SCHEME)
199            .ok_or(NwcError::UriBadScheme)?;
200        let (host, query) = match rest.split_once('?') {
201            Some((host, query)) => (host, query),
202            None => (rest, ""),
203        };
204        if host.is_empty() {
205            return Err(NwcError::UriMissingPubkey);
206        }
207        let wallet_pubkey = PublicKey::parse(host).map_err(NwcError::InvalidPublicKey)?;
208
209        let mut relays: Vec<RelayUrl> = Vec::new();
210        let mut secret: Option<SecretKey> = None;
211        let mut lud16: Option<String> = None;
212        for (key, value) in form_urlencoded::parse(query.as_bytes()) {
213            match key.as_ref() {
214                "relay" => {
215                    let url = RelayUrl::parse(value.as_ref()).map_err(NwcError::InvalidRelayUrl)?;
216                    relays.push(url);
217                }
218                "secret" => {
219                    secret =
220                        Some(SecretKey::parse(value.as_ref()).map_err(NwcError::InvalidSecretKey)?);
221                }
222                "lud16" => {
223                    lud16 = Some(value.into_owned());
224                }
225                _ => { /* ignore unknown parameters */ }
226            }
227        }
228        if relays.is_empty() {
229            return Err(NwcError::UriMissingRelay);
230        }
231        let secret = secret.ok_or(NwcError::UriMissingSecret)?;
232        Ok(Self {
233            wallet_pubkey,
234            relays,
235            secret,
236            lud16,
237        })
238    }
239
240    /// Render back to the wire `nostr+walletconnect://…` form.
241    #[must_use]
242    pub fn to_uri(&self) -> String {
243        let mut serializer = form_urlencoded::Serializer::new(String::new());
244        for relay in &self.relays {
245            serializer.append_pair("relay", relay.as_str());
246        }
247        serializer.append_pair("secret", &self.secret.to_hex());
248        if let Some(lud16) = &self.lud16 {
249            serializer.append_pair("lud16", lud16);
250        }
251        let query = serializer.finish();
252        format!("{URI_SCHEME}{}?{query}", self.wallet_pubkey.to_hex())
253    }
254}
255
256/// Typed bundle for a `kind: 13194` info event.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct InfoEvent {
259    /// Methods advertised in `content` (space-separated).
260    pub methods: Vec<String>,
261    /// `notifications` tag — supported notification types.
262    pub notifications: Vec<String>,
263    /// `encryption` tag — supported encryption schemes. Empty
264    /// when the tag was absent (which spec §"Encryption" reads as
265    /// "NIP-04 only").
266    pub encryption_schemes: Vec<Encryption>,
267}
268
269impl InfoEvent {
270    /// Construct an info bundle.
271    #[must_use]
272    pub const fn new() -> Self {
273        Self {
274            methods: Vec::new(),
275            notifications: Vec::new(),
276            encryption_schemes: Vec::new(),
277        }
278    }
279
280    /// Append a supported method.
281    #[must_use]
282    pub fn method(mut self, method: impl Into<String>) -> Self {
283        self.methods.push(method.into());
284        self
285    }
286
287    /// Append a supported notification type.
288    #[must_use]
289    pub fn notification(mut self, notification: impl Into<String>) -> Self {
290        self.notifications.push(notification.into());
291        self
292    }
293
294    /// Append a supported encryption scheme.
295    #[must_use]
296    pub fn encryption(mut self, scheme: Encryption) -> Self {
297        self.encryption_schemes.push(scheme);
298        self
299    }
300
301    /// Build the wire `content` string (space-separated methods).
302    #[must_use]
303    pub fn content(&self) -> String {
304        self.methods.join(" ")
305    }
306
307    /// Build the wire tag list.
308    #[must_use]
309    pub fn to_tags(&self) -> Vec<Tag> {
310        let mut tags: Vec<Tag> = Vec::with_capacity(2);
311        if !self.encryption_schemes.is_empty() {
312            let mut values: Vec<String> = Vec::with_capacity(self.encryption_schemes.len() + 1);
313            for scheme in &self.encryption_schemes {
314                values.push(scheme.as_token().to_owned());
315            }
316            tags.push(custom_tag(ENCRYPTION_TAG, [values.join(" ")]));
317        }
318        if !self.notifications.is_empty() {
319            tags.push(custom_tag(
320                NOTIFICATIONS_TAG,
321                [self.notifications.join(" ")],
322            ));
323        }
324        tags
325    }
326
327    /// Parse an info event back into a typed bundle.
328    ///
329    /// Spec §"Encryption": *"Absence of this tag implies that the
330    /// wallet only supports nip04."* — the parser keeps the empty
331    /// vector in [`Self::encryption_schemes`] and lets
332    /// [`Encryption::negotiate`] apply that rule.
333    ///
334    /// # Errors
335    ///
336    /// - [`NwcError::WrongKind`] for unrelated kinds.
337    /// - Forwarded parse errors for malformed encryption tokens.
338    pub fn from_event(event: &Event) -> Result<Self, NwcError> {
339        if event.kind != KIND_INFO {
340            return Err(NwcError::WrongKind(event.kind));
341        }
342        let methods = event
343            .content
344            .split_whitespace()
345            .map(str::to_owned)
346            .collect();
347        let mut notifications: Vec<String> = Vec::new();
348        let mut encryption_schemes: Vec<Encryption> = Vec::new();
349        for tag in &event.tags {
350            match tag.name() {
351                NOTIFICATIONS_TAG => parse_notifications_tag(tag, &mut notifications),
352                ENCRYPTION_TAG => parse_encryption_tag(tag, &mut encryption_schemes)?,
353                _ => {}
354            }
355        }
356        Ok(Self {
357            methods,
358            notifications,
359            encryption_schemes,
360        })
361    }
362}
363
364impl Default for InfoEvent {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370/// JSON-RPC request payload (`content` of a `kind: 23194` event
371/// after decryption).
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct Request {
374    /// Method name (e.g. `pay_invoice`).
375    pub method: String,
376    /// Method-specific parameters.
377    pub params: serde_json::Value,
378}
379
380/// JSON-RPC response payload (`content` of a `kind: 23195` event
381/// after decryption).
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct Response {
384    /// Echoes the method name from the original request.
385    pub result_type: String,
386    /// `null` on success, populated on error.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub error: Option<ResponseError>,
389    /// `null` on error, populated on success.
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub result: Option<serde_json::Value>,
392}
393
394/// Error envelope inside a [`Response`].
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct ResponseError {
397    /// Spec'd error code.
398    pub code: ErrorCode,
399    /// Human-readable error message.
400    pub message: String,
401}
402
403/// JSON-RPC notification payload (`content` of a `kind: 23197`
404/// event after decryption).
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct Notification {
407    /// Notification type (e.g. `payment_received`).
408    pub notification_type: String,
409    /// Notification-specific data.
410    pub notification: serde_json::Value,
411}
412
413/// Typed wallet error code (NIP-47 §"Error codes").
414#[derive(Debug, Clone, PartialEq, Eq, Hash)]
415#[non_exhaustive]
416pub enum ErrorCode {
417    /// `RATE_LIMITED`.
418    RateLimited,
419    /// `NOT_IMPLEMENTED`.
420    NotImplemented,
421    /// `INSUFFICIENT_BALANCE`.
422    InsufficientBalance,
423    /// `QUOTA_EXCEEDED`.
424    QuotaExceeded,
425    /// `RESTRICTED`.
426    Restricted,
427    /// `UNAUTHORIZED`.
428    Unauthorized,
429    /// `INTERNAL`.
430    Internal,
431    /// `UNSUPPORTED_ENCRYPTION`.
432    UnsupportedEncryption,
433    /// `PAYMENT_FAILED` — defined under `pay_invoice` /
434    /// `pay_keysend`.
435    PaymentFailed,
436    /// `NOT_FOUND` — defined under `lookup_invoice`.
437    NotFound,
438    /// `OTHER` — spec-listed catch-all.
439    Other,
440    /// Forward-compatible passthrough for unknown codes.
441    Custom(String),
442}
443
444impl ErrorCode {
445    /// Wire token.
446    ///
447    /// Returns the spec-defined uppercase code or, for [`Self::Custom`],
448    /// the borrowed inner string. The latter forbids a `const fn`.
449    #[must_use]
450    #[expect(
451        clippy::missing_const_for_fn,
452        reason = "`Self::Custom` borrows from a heap `String`"
453    )]
454    pub fn as_str(&self) -> &str {
455        match self {
456            Self::RateLimited => "RATE_LIMITED",
457            Self::NotImplemented => "NOT_IMPLEMENTED",
458            Self::InsufficientBalance => "INSUFFICIENT_BALANCE",
459            Self::QuotaExceeded => "QUOTA_EXCEEDED",
460            Self::Restricted => "RESTRICTED",
461            Self::Unauthorized => "UNAUTHORIZED",
462            Self::Internal => "INTERNAL",
463            Self::UnsupportedEncryption => "UNSUPPORTED_ENCRYPTION",
464            Self::PaymentFailed => "PAYMENT_FAILED",
465            Self::NotFound => "NOT_FOUND",
466            Self::Other => "OTHER",
467            Self::Custom(s) => s.as_str(),
468        }
469    }
470
471    /// Parse a wire token.
472    #[must_use]
473    pub fn parse(token: &str) -> Self {
474        match token {
475            "RATE_LIMITED" => Self::RateLimited,
476            "NOT_IMPLEMENTED" => Self::NotImplemented,
477            "INSUFFICIENT_BALANCE" => Self::InsufficientBalance,
478            "QUOTA_EXCEEDED" => Self::QuotaExceeded,
479            "RESTRICTED" => Self::Restricted,
480            "UNAUTHORIZED" => Self::Unauthorized,
481            "INTERNAL" => Self::Internal,
482            "UNSUPPORTED_ENCRYPTION" => Self::UnsupportedEncryption,
483            "PAYMENT_FAILED" => Self::PaymentFailed,
484            "NOT_FOUND" => Self::NotFound,
485            "OTHER" => Self::Other,
486            other => Self::Custom(other.to_owned()),
487        }
488    }
489}
490
491impl fmt::Display for ErrorCode {
492    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
493        f.write_str(self.as_str())
494    }
495}
496
497impl Serialize for ErrorCode {
498    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
499        s.serialize_str(self.as_str())
500    }
501}
502
503impl<'de> Deserialize<'de> for ErrorCode {
504    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
505        let s = String::deserialize(d)?;
506        Ok(Self::parse(&s))
507    }
508}
509
510fn parse_notifications_tag(tag: &Tag, out: &mut Vec<String>) {
511    if let Some(v) = tag.get(1) {
512        *out = v.split_whitespace().map(str::to_owned).collect();
513    }
514}
515
516fn parse_encryption_tag(tag: &Tag, out: &mut Vec<Encryption>) -> Result<(), NwcError> {
517    let Some(v) = tag.get(1) else { return Ok(()) };
518    for token in v.split_whitespace() {
519        out.push(Encryption::parse(token)?);
520    }
521    Ok(())
522}
523
524fn custom_tag<I, S>(name: &str, args: I) -> Tag
525where
526    I: IntoIterator<Item = S>,
527    S: Into<String>,
528{
529    Tag::with(&TagKind::from_wire(name), args)
530}
531
532fn p_tag(pubkey: PublicKey) -> Tag {
533    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
534    Tag::with(&head, [pubkey.to_hex()])
535}
536
537fn e_tag(id: crate::event::EventId) -> Tag {
538    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
539    Tag::with(&head, [id.to_hex()])
540}
541
542/// Errors raised by the NIP-47 helpers.
543#[derive(Debug, Error)]
544#[non_exhaustive]
545pub enum NwcError {
546    /// The wrapping event was not the expected kind.
547    #[error("unexpected kind {}", .0.as_u16())]
548    WrongKind(Kind),
549    /// URI scheme prefix did not match.
550    #[error("URI must start with `{URI_SCHEME}`")]
551    UriBadScheme,
552    /// URI host was empty.
553    #[error("URI is missing the wallet pubkey host")]
554    UriMissingPubkey,
555    /// No `relay` query parameter was provided.
556    #[error("URI is missing the `relay` query parameter")]
557    UriMissingRelay,
558    /// No `secret` query parameter was provided.
559    #[error("URI is missing the `secret` query parameter")]
560    UriMissingSecret,
561    /// Pubkey hex did not parse.
562    #[error("invalid public key: {0}")]
563    InvalidPublicKey(#[source] PublicKeyError),
564    /// Secret key hex did not parse.
565    #[error("invalid secret key: {0}")]
566    InvalidSecretKey(#[source] SecretKeyError),
567    /// Relay URL did not parse.
568    #[error("invalid relay URL: {0}")]
569    InvalidRelayUrl(#[source] RelayUrlError),
570    /// Encryption tag carried an unrecognised scheme.
571    #[error("unknown encryption scheme: {0}")]
572    UnknownEncryption(String),
573    /// Wallet and client could not agree on an encryption scheme.
574    #[error("client and wallet do not share a supported encryption scheme")]
575    EncryptionNotNegotiable,
576    /// JSON encode/decode failed.
577    #[error("invalid JSON-RPC payload: {0}")]
578    InvalidJson(#[source] serde_json::Error),
579    /// `p` tag column missing.
580    #[error("event missing required `p` tag")]
581    MissingPTag,
582    /// `e` tag column missing on a response.
583    #[error("response event missing required `e` tag")]
584    MissingETag,
585    /// NIP-44 encrypt/decrypt failed.
586    #[cfg(feature = "nip44")]
587    #[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
588    #[error("NIP-44 failure: {0}")]
589    Nip44(#[source] nip44::Nip44Error),
590    /// NIP-04 encrypt/decrypt failed.
591    #[cfg(feature = "nip04")]
592    #[cfg_attr(docsrs, doc(cfg(feature = "nip04")))]
593    #[error("NIP-04 failure: {0}")]
594    Nip04(#[source] nip04::Nip04Error),
595    /// Caller asked for NIP-04 but the `nip04` feature is off.
596    #[cfg(not(feature = "nip04"))]
597    #[error("NIP-04 fallback required but the `nip04` feature is disabled")]
598    Nip04Unavailable,
599}
600
601#[cfg(feature = "nip44")]
602fn encrypt_with(
603    encryption: Encryption,
604    secret: &SecretKey,
605    peer: &PublicKey,
606    plaintext: &str,
607) -> Result<String, NwcError> {
608    match encryption {
609        Encryption::Nip44V2 => nip44::encrypt(secret, peer, plaintext).map_err(NwcError::Nip44),
610        #[cfg(feature = "nip04")]
611        Encryption::Nip04 => nip04::encrypt(secret, peer, plaintext).map_err(NwcError::Nip04),
612        #[cfg(not(feature = "nip04"))]
613        Encryption::Nip04 => Err(NwcError::Nip04Unavailable),
614    }
615}
616
617#[cfg(feature = "nip44")]
618fn decrypt_with(
619    encryption: Encryption,
620    secret: &SecretKey,
621    peer: &PublicKey,
622    payload: &str,
623) -> Result<String, NwcError> {
624    match encryption {
625        Encryption::Nip44V2 => nip44::decrypt(secret, peer, payload).map_err(NwcError::Nip44),
626        #[cfg(feature = "nip04")]
627        Encryption::Nip04 => nip04::decrypt(secret, peer, payload).map_err(NwcError::Nip04),
628        #[cfg(not(feature = "nip04"))]
629        Encryption::Nip04 => Err(NwcError::Nip04Unavailable),
630    }
631}
632
633/// Inspect an event's `encryption` tag to learn which scheme the
634/// peer used. Absence of the tag implies NIP-04 per spec.
635///
636/// # Errors
637///
638/// Returns [`NwcError::UnknownEncryption`] if the tag value is
639/// not one of the schemes defined in §2.
640pub fn encryption_for_event(event: &Event) -> Result<Encryption, NwcError> {
641    for tag in &event.tags {
642        if tag.name() == ENCRYPTION_TAG
643            && let Some(token) = tag.get(1)
644        {
645            return Encryption::parse(token);
646        }
647    }
648    Ok(Encryption::Nip04)
649}
650
651#[cfg(feature = "nip44")]
652#[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
653impl EventBuilder {
654    /// Author a NIP-47 info event from a typed bundle. The author
655    /// SHOULD be the wallet service's pubkey.
656    #[must_use]
657    pub fn nwc_info(info: &InfoEvent) -> Self {
658        let mut builder = Self::new(KIND_INFO, info.content());
659        for tag in info.to_tags() {
660            builder = builder.tag(tag);
661        }
662        builder
663    }
664
665    /// Build a NIP-47 request event with encrypted body.
666    ///
667    /// `client_secret` is the URI's `secret`; `wallet_public` is
668    /// the URI's host. `expiration` populates a NIP-40 expiration
669    /// tag when set — spec §"Request and Response Events" treats
670    /// the timestamp as a hard cut-off the wallet service may use
671    /// to drop late requests.
672    ///
673    /// # Errors
674    ///
675    /// - [`NwcError::InvalidJson`] when `request` cannot be
676    ///   serialised.
677    /// - [`NwcError::Nip44`] / [`NwcError::Nip04`] from the
678    ///   underlying encryption primitives.
679    pub fn nwc_request(
680        client_secret: &SecretKey,
681        wallet_public: &PublicKey,
682        request: &Request,
683        encryption: Encryption,
684        expiration: Option<Timestamp>,
685    ) -> Result<Self, NwcError> {
686        let plaintext = serde_json::to_string(request).map_err(NwcError::InvalidJson)?;
687        let ciphertext = encrypt_with(encryption, client_secret, wallet_public, &plaintext)?;
688        let mut builder = Self::new(KIND_REQUEST, ciphertext)
689            .tag(p_tag(*wallet_public))
690            .tag(custom_tag(ENCRYPTION_TAG, [encryption.as_token()]));
691        if let Some(ts) = expiration {
692            builder = builder.expiration(ts);
693        }
694        Ok(builder)
695    }
696
697    /// Build a NIP-47 response event with encrypted body.
698    ///
699    /// # Errors
700    ///
701    /// See [`Self::nwc_request`].
702    pub fn nwc_response(
703        wallet_secret: &SecretKey,
704        client_public: &PublicKey,
705        request_event_id: crate::event::EventId,
706        response: &Response,
707        encryption: Encryption,
708    ) -> Result<Self, NwcError> {
709        let plaintext = serde_json::to_string(response).map_err(NwcError::InvalidJson)?;
710        let ciphertext = encrypt_with(encryption, wallet_secret, client_public, &plaintext)?;
711        Ok(Self::new(KIND_RESPONSE, ciphertext)
712            .tag(p_tag(*client_public))
713            .tag(e_tag(request_event_id))
714            .tag(custom_tag(ENCRYPTION_TAG, [encryption.as_token()])))
715    }
716
717    /// Build a NIP-47 notification event with encrypted body.
718    ///
719    /// `kind` should be [`KIND_NOTIFICATION`] for NIP-44 or
720    /// [`KIND_NOTIFICATION_LEGACY`] for NIP-04.
721    ///
722    /// # Errors
723    ///
724    /// See [`Self::nwc_request`].
725    pub fn nwc_notification(
726        wallet_secret: &SecretKey,
727        client_public: &PublicKey,
728        notification: &Notification,
729        encryption: Encryption,
730    ) -> Result<Self, NwcError> {
731        let kind = match encryption {
732            Encryption::Nip44V2 => KIND_NOTIFICATION,
733            Encryption::Nip04 => KIND_NOTIFICATION_LEGACY,
734        };
735        let plaintext = serde_json::to_string(notification).map_err(NwcError::InvalidJson)?;
736        let ciphertext = encrypt_with(encryption, wallet_secret, client_public, &plaintext)?;
737        Ok(Self::new(kind, ciphertext)
738            .tag(p_tag(*client_public))
739            .tag(custom_tag(ENCRYPTION_TAG, [encryption.as_token()])))
740    }
741}
742
743/// Decrypt and parse a `kind: 23194` request event.
744///
745/// `wallet_secret` is the wallet service's secret key;
746/// `client_public` MUST come from the *event signature* (not from
747/// any tag) and is typically `event.pubkey`.
748///
749/// # Errors
750///
751/// - [`NwcError::WrongKind`] for unrelated kinds.
752/// - Forwarded errors from the encryption primitives and
753///   `serde_json`.
754#[cfg(feature = "nip44")]
755#[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
756pub fn decrypt_request(event: &Event, wallet_secret: &SecretKey) -> Result<Request, NwcError> {
757    if event.kind != KIND_REQUEST {
758        return Err(NwcError::WrongKind(event.kind));
759    }
760    let encryption = encryption_for_event(event)?;
761    let plaintext = decrypt_with(encryption, wallet_secret, &event.pubkey, &event.content)?;
762    serde_json::from_str(&plaintext).map_err(NwcError::InvalidJson)
763}
764
765/// Decrypt and parse a `kind: 23195` response event.
766///
767/// # Errors
768///
769/// See [`decrypt_request`].
770#[cfg(feature = "nip44")]
771#[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
772pub fn decrypt_response(event: &Event, client_secret: &SecretKey) -> Result<Response, NwcError> {
773    if event.kind != KIND_RESPONSE {
774        return Err(NwcError::WrongKind(event.kind));
775    }
776    let encryption = encryption_for_event(event)?;
777    let plaintext = decrypt_with(encryption, client_secret, &event.pubkey, &event.content)?;
778    serde_json::from_str(&plaintext).map_err(NwcError::InvalidJson)
779}
780
781/// Decrypt and parse a notification event (`kind: 23197` or
782/// `kind: 23196`).
783///
784/// # Errors
785///
786/// See [`decrypt_request`].
787#[cfg(feature = "nip44")]
788#[cfg_attr(docsrs, doc(cfg(feature = "nip44")))]
789pub fn decrypt_notification(
790    event: &Event,
791    client_secret: &SecretKey,
792) -> Result<Notification, NwcError> {
793    if event.kind != KIND_NOTIFICATION && event.kind != KIND_NOTIFICATION_LEGACY {
794        return Err(NwcError::WrongKind(event.kind));
795    }
796    let encryption = encryption_for_event(event)?;
797    let plaintext = decrypt_with(encryption, client_secret, &event.pubkey, &event.content)?;
798    serde_json::from_str(&plaintext).map_err(NwcError::InvalidJson)
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804    use crate::Keys;
805
806    fn wallet() -> Keys {
807        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
808    }
809
810    fn client() -> Keys {
811        Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
812    }
813
814    #[test]
815    fn connection_uri_round_trips_with_lud16() {
816        let uri = ConnectionUri {
817            wallet_pubkey: *wallet().public_key(),
818            relays: vec![
819                RelayUrl::parse("wss://relay.one/").unwrap(),
820                RelayUrl::parse("wss://relay.two/").unwrap(),
821            ],
822            secret: client().secret_key().clone(),
823            lud16: Some("alice@example.com".to_owned()),
824        };
825        let wire = uri.to_uri();
826        let parsed = ConnectionUri::parse(&wire).unwrap();
827        assert_eq!(parsed, uri);
828    }
829
830    #[test]
831    fn connection_uri_rejects_bad_scheme() {
832        let err = ConnectionUri::parse("https://example.com/").unwrap_err();
833        assert!(matches!(err, NwcError::UriBadScheme));
834    }
835
836    #[test]
837    fn connection_uri_requires_relay_and_secret() {
838        let pk = wallet().public_key().to_hex();
839        let no_relay = format!(
840            "nostr+walletconnect://{pk}?secret={}",
841            client().secret_key().to_hex()
842        );
843        assert!(matches!(
844            ConnectionUri::parse(&no_relay),
845            Err(NwcError::UriMissingRelay)
846        ));
847        let no_secret = format!("nostr+walletconnect://{pk}?relay=wss%3A%2F%2Frelay/");
848        assert!(matches!(
849            ConnectionUri::parse(&no_secret),
850            Err(NwcError::UriMissingSecret)
851        ));
852    }
853
854    #[test]
855    fn info_event_round_trips() {
856        let info = InfoEvent::new()
857            .method("pay_invoice")
858            .method("get_balance")
859            .notification("payment_received")
860            .encryption(Encryption::Nip44V2)
861            .encryption(Encryption::Nip04);
862        let event = EventBuilder::nwc_info(&info)
863            .sign_with_keys(&wallet())
864            .unwrap();
865        assert_eq!(event.kind, KIND_INFO);
866        let parsed = InfoEvent::from_event(&event).unwrap();
867        assert_eq!(parsed.methods, vec!["pay_invoice", "get_balance"]);
868        assert_eq!(parsed.notifications, vec!["payment_received"]);
869        assert_eq!(
870            parsed.encryption_schemes,
871            vec![Encryption::Nip44V2, Encryption::Nip04]
872        );
873    }
874
875    #[test]
876    fn info_event_without_encryption_tag_is_nip04_only() {
877        let event = EventBuilder::new(KIND_INFO, "pay_invoice")
878            .sign_with_keys(&wallet())
879            .unwrap();
880        let info = InfoEvent::from_event(&event).unwrap();
881        assert!(info.encryption_schemes.is_empty());
882        let scheme = Encryption::negotiate(
883            &info.encryption_schemes,
884            &[Encryption::Nip44V2, Encryption::Nip04],
885        )
886        .unwrap();
887        assert_eq!(scheme, Encryption::Nip04);
888    }
889
890    #[test]
891    fn encryption_negotiation_prefers_nip44_v2() {
892        let scheme = Encryption::negotiate(
893            &[Encryption::Nip44V2, Encryption::Nip04],
894            &[Encryption::Nip44V2, Encryption::Nip04],
895        )
896        .unwrap();
897        assert_eq!(scheme, Encryption::Nip44V2);
898    }
899
900    #[test]
901    fn encryption_negotiation_falls_back_to_nip04_when_only_overlap() {
902        let scheme = Encryption::negotiate(
903            &[Encryption::Nip04],
904            &[Encryption::Nip44V2, Encryption::Nip04],
905        )
906        .unwrap();
907        assert_eq!(scheme, Encryption::Nip04);
908    }
909
910    #[test]
911    fn encryption_negotiation_fails_when_no_overlap() {
912        let err = Encryption::negotiate(&[Encryption::Nip04], &[Encryption::Nip44V2]).unwrap_err();
913        assert!(matches!(err, NwcError::EncryptionNotNegotiable));
914    }
915
916    #[test]
917    fn error_code_round_trips_through_serde() {
918        let code = ErrorCode::PaymentFailed;
919        let json = serde_json::to_string(&code).unwrap();
920        assert_eq!(json, "\"PAYMENT_FAILED\"");
921        let parsed: ErrorCode = serde_json::from_str(&json).unwrap();
922        assert_eq!(parsed, code);
923    }
924
925    #[test]
926    fn error_code_unknown_passes_through_as_custom() {
927        let code: ErrorCode = serde_json::from_str("\"FUTURE_CODE\"").unwrap();
928        assert_eq!(code, ErrorCode::Custom("FUTURE_CODE".to_owned()));
929    }
930
931    #[cfg(feature = "nip44")]
932    #[test]
933    fn request_response_round_trip_through_nip44() {
934        let request = Request {
935            method: "pay_invoice".to_owned(),
936            params: serde_json::json!({ "invoice": "lnbc1..." }),
937        };
938        let req_event = EventBuilder::nwc_request(
939            client().secret_key(),
940            wallet().public_key(),
941            &request,
942            Encryption::Nip44V2,
943            None,
944        )
945        .unwrap()
946        .sign_with_keys(&client())
947        .unwrap();
948
949        let parsed = decrypt_request(&req_event, wallet().secret_key()).unwrap();
950        assert_eq!(parsed, request);
951
952        let response = Response {
953            result_type: "pay_invoice".to_owned(),
954            error: None,
955            result: Some(serde_json::json!({ "preimage": "deadbeef" })),
956        };
957        let resp_event = EventBuilder::nwc_response(
958            wallet().secret_key(),
959            client().public_key(),
960            req_event.id,
961            &response,
962            Encryption::Nip44V2,
963        )
964        .unwrap()
965        .sign_with_keys(&wallet())
966        .unwrap();
967
968        let parsed_resp = decrypt_response(&resp_event, client().secret_key()).unwrap();
969        assert_eq!(parsed_resp, response);
970    }
971
972    #[cfg(all(feature = "nip44", feature = "nip04"))]
973    #[test]
974    fn legacy_notification_uses_nip04_kind_and_works_end_to_end() {
975        let notification = Notification {
976            notification_type: "payment_received".to_owned(),
977            notification: serde_json::json!({ "payment_hash": "abc" }),
978        };
979        let event = EventBuilder::nwc_notification(
980            wallet().secret_key(),
981            client().public_key(),
982            &notification,
983            Encryption::Nip04,
984        )
985        .unwrap()
986        .sign_with_keys(&wallet())
987        .unwrap();
988        assert_eq!(event.kind, KIND_NOTIFICATION_LEGACY);
989
990        let parsed = decrypt_notification(&event, client().secret_key()).unwrap();
991        assert_eq!(parsed, notification);
992    }
993
994    #[cfg(feature = "nip44")]
995    #[test]
996    fn request_with_expiration_attaches_nip40_tag() {
997        let request = Request {
998            method: "get_balance".to_owned(),
999            params: serde_json::json!({}),
1000        };
1001        let event = EventBuilder::nwc_request(
1002            client().secret_key(),
1003            wallet().public_key(),
1004            &request,
1005            Encryption::Nip44V2,
1006            Some(Timestamp::from_secs(2_000_000_000)),
1007        )
1008        .unwrap()
1009        .sign_with_keys(&client())
1010        .unwrap();
1011        let has_expiration = event.tags.iter().any(|t| t.name() == "expiration");
1012        assert!(has_expiration);
1013    }
1014}