Skip to main content

nula_core/nips/
nip46.rs

1//! [NIP-46] Nostr Connect — protocol primitives.
2//!
3//! NIP-46 specifies an asynchronous JSON-RPC interface between a
4//! *client* (an app that owns no signing keys) and a *remote signer*
5//! (a bunker or browser extension that does). Messages travel over
6//! Nostr itself: each request/response is a kind-`24133` event whose
7//! `content` is the JSON-RPC body, encrypted with [NIP-44] v2 to the
8//! peer's public key.
9//!
10//! `nula-core` ships the **protocol primitives** — message types,
11//! method enum, URI parser. The actual transport (relay subscription,
12//! request bookkeeping, timeout policy, retry logic) belongs to a
13//! higher crate that owns a relay client; the surface here is enough
14//! to encode/decode every wire payload and to negotiate the initial
15//! handshake.
16//!
17//! # Method matrix
18//!
19//! | Method            | Request payload                            | Response payload                            |
20//! |-------------------|--------------------------------------------|---------------------------------------------|
21//! | `connect`         | `[remote_pubkey, secret?, perms?]`         | `"ack"` or echoed secret                    |
22//! | `get_public_key`  | `[]`                                       | user's pubkey (hex)                         |
23//! | `sign_event`      | `[unsigned_json]`                          | signed event JSON                           |
24//! | `nip04_encrypt`   | `[peer_pubkey, plaintext]`                 | base64 ciphertext                           |
25//! | `nip04_decrypt`   | `[peer_pubkey, ciphertext]`                | plaintext                                   |
26//! | `nip44_encrypt`   | `[peer_pubkey, plaintext]`                 | base64 ciphertext                           |
27//! | `nip44_decrypt`   | `[peer_pubkey, ciphertext]`                | plaintext                                   |
28//! | `ping`            | `[]`                                       | `"pong"`                                    |
29//! | `switch_relays`   | `[]`                                       | JSON array of relay URLs, or `null`         |
30//!
31//! Any method may instead return `"auth_url"` (signaling that the user
32//! must complete an out-of-band auth step) or `"error"` with the
33//! `error` field populated.
34//!
35//! # Permissions (NIP-46 § "Requested permissions")
36//!
37//! The third positional slot of `connect` carries a comma-separated
38//! list of `method[:params]` tokens, e.g. `nip44_encrypt,sign_event:4`.
39//! Each token is modelled as a [`Permission`]; the typed enum keeps the
40//! two well-defined shapes (blanket method, `sign_event` restricted to
41//! a kind) and falls back to [`Permission::Other`] for vendor or
42//! future-spec extensions.
43//!
44//! # Connection URIs
45//!
46//! Two kinds:
47//!
48//! - **`bunker://<remote_pubkey>?relay=...&secret=...`** — signer
49//!   advertises its address; client dials in.
50//! - **`nostrconnect://<client_pubkey>?relay=...&metadata=...&secret=...`** —
51//!   client advertises itself; signer dials in. NIP-46 mandates that
52//!   the `secret` field is present and that the signer echoes it back
53//!   inside the `connect` response (anti-spoofing).
54//!
55//! [NIP-46]: https://github.com/nostr-protocol/nips/blob/master/46.md
56//! [NIP-44]: https://github.com/nostr-protocol/nips/blob/master/44.md
57
58use std::fmt;
59use std::str::FromStr;
60
61use serde::{Deserialize, Deserializer, Serialize, Serializer};
62use thiserror::Error;
63use url::Url;
64
65use crate::event::{Event, EventError, Kind, UnsignedEvent, UnsignedEventError};
66use crate::key::{PublicKey, PublicKeyError};
67use crate::types::{RelayUrl, RelayUrlError};
68use crate::util::JsonUtil;
69
70/// URI scheme for client-initiated connections (`nostrconnect://…`).
71pub const URI_SCHEME_CLIENT: &str = "nostrconnect";
72/// URI scheme for signer-initiated connections (`bunker://…`).
73pub const URI_SCHEME_BUNKER: &str = "bunker";
74
75/// Kind of a NIP-46 wire event.
76///
77/// Re-exposed as a constant for callers building filters or routing
78/// dispatchers without importing the magic number.
79pub const KIND: u16 = 24_133;
80
81/// Errors raised by the NIP-46 helpers.
82#[derive(Debug, Error)]
83#[non_exhaustive]
84pub enum Nip46Error {
85    /// A hex-encoded pubkey in the request/URI was invalid.
86    #[error(transparent)]
87    PublicKey(#[from] PublicKeyError),
88    /// A relay URL inside a connection URI was invalid.
89    #[error(transparent)]
90    RelayUrl(#[from] RelayUrlError),
91    /// `serde_json` rejected the wire payload.
92    #[error("invalid JSON payload: {0}")]
93    Json(#[from] serde_json::Error),
94    /// An unsigned event JSON in `sign_event` request failed to parse.
95    #[error(transparent)]
96    UnsignedEvent(#[from] UnsignedEventError),
97    /// A signed event JSON in a `sign_event` response failed to parse.
98    #[error(transparent)]
99    Event(#[from] EventError),
100    /// A request had the wrong number of params for its method.
101    #[error("method `{method}` expects {expected} param(s), got {actual}")]
102    InvalidParamLength {
103        /// Method that was being parsed.
104        method: Method,
105        /// Number of params the method requires.
106        expected: usize,
107        /// Number of params the message actually carried.
108        actual: usize,
109    },
110    /// The wire `method` field is not one of the nine defined methods.
111    #[error("unsupported NIP-46 method: {0}")]
112    UnsupportedMethod(String),
113    /// The `switch_relays` response carried JSON that was neither
114    /// `null` nor an array of relay URL strings.
115    #[error("invalid switch_relays response payload")]
116    InvalidSwitchRelaysPayload,
117    /// Tried to convert a [`Message::Response`] to a [`Request`] (or
118    /// vice-versa).
119    #[error("{0}")]
120    WrongMessageKind(&'static str),
121    /// The connection URI's scheme was neither `bunker` nor
122    /// `nostrconnect`.
123    #[error("unknown URI scheme `{0}` (expected `bunker` or `nostrconnect`)")]
124    UnknownUriScheme(String),
125    /// The connection URI was missing a required component.
126    #[error("malformed connection URI: {0}")]
127    MalformedUri(&'static str),
128    /// The base URL parser rejected the URI.
129    #[error(transparent)]
130    Url(#[from] url::ParseError),
131    /// The result returned by the signer didn't match the request's
132    /// method (e.g. asked to `sign_event`, got a `pong` back).
133    #[error("unexpected response for method `{method}` (expected {expected}, got `{received}`)")]
134    UnexpectedResponse {
135        /// The method whose response was being decoded.
136        method: Method,
137        /// Short label of the expected response shape.
138        expected: &'static str,
139        /// What the wire actually carried.
140        received: String,
141    },
142}
143
144/// Bare request method (the string in the wire `method` field).
145#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
146#[non_exhaustive]
147pub enum Method {
148    /// Negotiate or echo the connection (anti-spoofing).
149    Connect,
150    /// Return the user's BIP-340 public key.
151    GetPublicKey,
152    /// Sign an [`UnsignedEvent`] on the user's behalf.
153    SignEvent,
154    /// NIP-04 (legacy) encrypt.
155    Nip04Encrypt,
156    /// NIP-04 (legacy) decrypt.
157    Nip04Decrypt,
158    /// NIP-44 v2 encrypt.
159    Nip44Encrypt,
160    /// NIP-44 v2 decrypt.
161    Nip44Decrypt,
162    /// Liveness probe.
163    Ping,
164    /// Ask the remote signer for its preferred relay set
165    /// (NIP-46 § "Switching relays").
166    SwitchRelays,
167}
168
169impl Method {
170    /// Wire identifier (lowercase, `snake_case`).
171    #[must_use]
172    pub const fn as_str(self) -> &'static str {
173        match self {
174            Self::Connect => "connect",
175            Self::GetPublicKey => "get_public_key",
176            Self::SignEvent => "sign_event",
177            Self::Nip04Encrypt => "nip04_encrypt",
178            Self::Nip04Decrypt => "nip04_decrypt",
179            Self::Nip44Encrypt => "nip44_encrypt",
180            Self::Nip44Decrypt => "nip44_decrypt",
181            Self::Ping => "ping",
182            Self::SwitchRelays => "switch_relays",
183        }
184    }
185}
186
187impl fmt::Display for Method {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        f.write_str(self.as_str())
190    }
191}
192
193impl FromStr for Method {
194    type Err = Nip46Error;
195
196    fn from_str(s: &str) -> Result<Self, Self::Err> {
197        Ok(match s {
198            "connect" => Self::Connect,
199            "get_public_key" => Self::GetPublicKey,
200            "sign_event" => Self::SignEvent,
201            "nip04_encrypt" => Self::Nip04Encrypt,
202            "nip04_decrypt" => Self::Nip04Decrypt,
203            "nip44_encrypt" => Self::Nip44Encrypt,
204            "nip44_decrypt" => Self::Nip44Decrypt,
205            "ping" => Self::Ping,
206            "switch_relays" => Self::SwitchRelays,
207            other => return Err(Nip46Error::UnsupportedMethod(other.to_owned())),
208        })
209    }
210}
211
212/// A single permission entry inside the `connect` request's third
213/// positional slot.
214///
215/// Wire format: `method[:params]`, comma-separated when packed into the
216/// surrounding string. Spec line 112 reserves "parameters for other
217/// methods are to be defined later", so anything that does not match a
218/// known shape falls into [`Self::Other`] verbatim — round-tripping is
219/// always lossless.
220///
221/// # Examples
222///
223/// ```
224/// use nula_core::Kind;
225/// use nula_core::nips::nip46::{Method, Permission};
226///
227/// assert_eq!(
228///     "nip44_encrypt".parse::<Permission>().unwrap(),
229///     Permission::Method(Method::Nip44Encrypt),
230/// );
231/// assert_eq!(
232///     "sign_event:1".parse::<Permission>().unwrap(),
233///     Permission::SignEventKind(Kind::TEXT_NOTE),
234/// );
235/// ```
236#[derive(Debug, Clone, PartialEq, Eq, Hash)]
237#[non_exhaustive]
238pub enum Permission {
239    /// Blanket permission to call a known method with no further
240    /// parameter constraints (`<method>` form).
241    Method(Method),
242    /// Permission to call `sign_event` restricted to one event kind
243    /// (`sign_event:<kind>` form).
244    SignEventKind(Kind),
245    /// Forward-compat: a vendor extension or a shape the spec has not
246    /// standardised yet. Stored verbatim so encoding is lossless.
247    Other(String),
248}
249
250impl Permission {
251    /// Encode a single permission as its wire token (`method[:params]`).
252    #[must_use]
253    pub fn to_wire(&self) -> String {
254        match self {
255            Self::Method(method) => method.to_string(),
256            Self::SignEventKind(kind) => {
257                format!("{}:{}", Method::SignEvent.as_str(), kind.as_u16())
258            }
259            Self::Other(raw) => raw.clone(),
260        }
261    }
262
263    /// Encode a list of permissions as a single comma-separated wire
264    /// string (suitable for the `connect` request's third positional
265    /// slot or the `nostrconnect://?perms=` query parameter).
266    #[must_use]
267    pub fn join(perms: &[Self]) -> String {
268        let mut out = String::new();
269        for (i, perm) in perms.iter().enumerate() {
270            if i > 0 {
271                out.push(',');
272            }
273            out.push_str(&perm.to_wire());
274        }
275        out
276    }
277
278    /// Decode a comma-separated wire string into a vector of
279    /// permissions. Empty input yields an empty vector; whitespace
280    /// around each token is trimmed.
281    #[must_use]
282    pub fn split(wire: &str) -> Vec<Self> {
283        if wire.is_empty() {
284            return Vec::new();
285        }
286        wire.split(',')
287            .map(str::trim)
288            .filter(|tok| !tok.is_empty())
289            .map(|tok| tok.parse().unwrap_or_else(|_| Self::Other(tok.to_owned())))
290            .collect()
291    }
292}
293
294impl fmt::Display for Permission {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        f.write_str(&self.to_wire())
297    }
298}
299
300impl FromStr for Permission {
301    type Err = Nip46Error;
302
303    fn from_str(s: &str) -> Result<Self, Self::Err> {
304        // `method[:params]` — split on the first colon.
305        if let Some((head, tail)) = s.split_once(':') {
306            if head == Method::SignEvent.as_str()
307                && let Ok(raw) = tail.parse::<u16>()
308            {
309                return Ok(Self::SignEventKind(Kind::new(raw)));
310            }
311            // Known method with an unknown parameter shape, or a
312            // vendor namespace — preserve verbatim.
313            return Ok(Self::Other(s.to_owned()));
314        }
315        // No colon: bare method name. Unknown vocabulary falls
316        // through to the spec-mandated `Other` passthrough.
317        Ok(s.parse::<Method>()
318            .map_or_else(|_| Self::Other(s.to_owned()), Self::Method))
319    }
320}
321
322impl Serialize for Method {
323    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
324        serializer.serialize_str(self.as_str())
325    }
326}
327
328impl<'de> Deserialize<'de> for Method {
329    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
330        let raw = <&str>::deserialize(deserializer)?;
331        Self::from_str(raw).map_err(serde::de::Error::custom)
332    }
333}
334
335/// Typed request payload.
336///
337/// Each variant carries exactly the data its method needs; converting
338/// to and from the wire `Vec<String>` happens inside this module.
339#[derive(Debug, Clone, PartialEq, Eq)]
340#[non_exhaustive]
341pub enum Request {
342    /// `connect` — present the remote signer's public key (and an
343    /// optional one-time secret) to negotiate the session.
344    ///
345    /// `perms` carries the optional third positional argument from
346    /// NIP-46 § "Requested permissions"; `None` means the caller did
347    /// not negotiate any (the slot is omitted on the wire),
348    /// `Some(vec![])` means an explicitly empty set is being sent.
349    Connect {
350        /// Remote signer's pubkey.
351        remote_signer_public_key: PublicKey,
352        /// Optional anti-spoofing secret (carried in `bunker://`
353        /// URIs and required in `nostrconnect://` URIs).
354        secret: Option<String>,
355        /// Optional requested permissions.
356        perms: Option<Vec<Permission>>,
357    },
358    /// `get_public_key` — return the user's pubkey.
359    GetPublicKey,
360    /// `sign_event` — sign the given unsigned event.
361    SignEvent(UnsignedEvent),
362    /// `nip04_encrypt` — encrypt `text` for `peer`.
363    Nip04Encrypt {
364        /// Peer's pubkey.
365        peer: PublicKey,
366        /// UTF-8 plaintext.
367        text: String,
368    },
369    /// `nip04_decrypt` — decrypt `ciphertext` from `peer`.
370    Nip04Decrypt {
371        /// Peer's pubkey.
372        peer: PublicKey,
373        /// Wire-format ciphertext.
374        ciphertext: String,
375    },
376    /// `nip44_encrypt` — encrypt `text` for `peer` with NIP-44 v2.
377    Nip44Encrypt {
378        /// Peer's pubkey.
379        peer: PublicKey,
380        /// UTF-8 plaintext.
381        text: String,
382    },
383    /// `nip44_decrypt` — decrypt `ciphertext` from `peer` with NIP-44 v2.
384    Nip44Decrypt {
385        /// Peer's pubkey.
386        peer: PublicKey,
387        /// Wire-format ciphertext.
388        ciphertext: String,
389    },
390    /// `ping` — liveness probe.
391    Ping,
392    /// `switch_relays` — ask the signer for its preferred relay set.
393    SwitchRelays,
394}
395
396impl Request {
397    /// The method this request implements.
398    #[must_use]
399    pub const fn method(&self) -> Method {
400        match self {
401            Self::Connect { .. } => Method::Connect,
402            Self::GetPublicKey => Method::GetPublicKey,
403            Self::SignEvent(_) => Method::SignEvent,
404            Self::Nip04Encrypt { .. } => Method::Nip04Encrypt,
405            Self::Nip04Decrypt { .. } => Method::Nip04Decrypt,
406            Self::Nip44Encrypt { .. } => Method::Nip44Encrypt,
407            Self::Nip44Decrypt { .. } => Method::Nip44Decrypt,
408            Self::Ping => Method::Ping,
409            Self::SwitchRelays => Method::SwitchRelays,
410        }
411    }
412
413    /// Wire-format params (the `params` JSON array).
414    ///
415    /// `connect` follows the spec's positional layout
416    /// `[pubkey, secret?, perms?]`. Because the slots are positional,
417    /// emitting `perms` requires emitting a `secret` slot first; an
418    /// absent secret is encoded as the empty string so the perms slot
419    /// stays at index 2.
420    #[must_use]
421    pub fn params(&self) -> Vec<String> {
422        match self {
423            Self::Connect {
424                remote_signer_public_key,
425                secret,
426                perms,
427            } => {
428                let mut out = Vec::with_capacity(
429                    1 + usize::from(secret.is_some()) + usize::from(perms.is_some()),
430                );
431                out.push(remote_signer_public_key.to_hex());
432                if perms.is_some() {
433                    // perms occupies position 2, so a placeholder for
434                    // an absent secret keeps the layout positional.
435                    out.push(secret.clone().unwrap_or_default());
436                } else if let Some(s) = secret {
437                    out.push(s.clone());
438                }
439                if let Some(perms) = perms {
440                    out.push(Permission::join(perms));
441                }
442                out
443            }
444            Self::GetPublicKey | Self::Ping | Self::SwitchRelays => Vec::new(),
445            Self::SignEvent(unsigned) => vec![unsigned.try_to_json().unwrap_or_default()],
446            Self::Nip04Encrypt { peer, text } | Self::Nip44Encrypt { peer, text } => {
447                vec![peer.to_hex(), text.clone()]
448            }
449            Self::Nip04Decrypt { peer, ciphertext } | Self::Nip44Decrypt { peer, ciphertext } => {
450                vec![peer.to_hex(), ciphertext.clone()]
451            }
452        }
453    }
454
455    /// Parse a `(method, params)` pair into a typed request.
456    ///
457    /// Implemented as a single match over `(method, params.as_slice())`
458    /// so the slice patterns simultaneously bind, validate the arity,
459    /// and avoid `params[i]` indexing (which clippy flags as
460    /// potentially panicking).
461    ///
462    /// # Errors
463    ///
464    /// See [`Nip46Error`] for the failure surface; in particular,
465    /// [`Nip46Error::InvalidParamLength`] when a method receives the
466    /// wrong number of params.
467    pub fn from_wire(method: Method, params: &[String]) -> Result<Self, Nip46Error> {
468        match (method, params) {
469            // Happy paths — slice patterns simultaneously bind, validate
470            // arity, and avoid `params[i]` indexing that clippy flags
471            // as potentially panicking.
472            (Method::Connect, [pk_hex]) => Ok(Self::Connect {
473                remote_signer_public_key: PublicKey::parse(pk_hex)?,
474                secret: None,
475                perms: None,
476            }),
477            (Method::Connect, [pk_hex, secret]) => Ok(Self::Connect {
478                remote_signer_public_key: PublicKey::parse(pk_hex)?,
479                secret: Some(secret.clone()),
480                perms: None,
481            }),
482            (Method::Connect, [pk_hex, secret, perms]) => Ok(Self::Connect {
483                remote_signer_public_key: PublicKey::parse(pk_hex)?,
484                // An empty secret slot is a placeholder for "no secret
485                // but perms present" — collapse it back to `None` so
486                // round-trips preserve the original semantic shape.
487                secret: if secret.is_empty() {
488                    None
489                } else {
490                    Some(secret.clone())
491                },
492                perms: Some(Permission::split(perms)),
493            }),
494            (Method::GetPublicKey, []) => Ok(Self::GetPublicKey),
495            (Method::SignEvent, [json]) => Ok(Self::SignEvent(UnsignedEvent::from_json(json)?)),
496            (Method::Nip04Encrypt, [pk_hex, text]) => Ok(Self::Nip04Encrypt {
497                peer: PublicKey::parse(pk_hex)?,
498                text: text.clone(),
499            }),
500            (Method::Nip44Encrypt, [pk_hex, text]) => Ok(Self::Nip44Encrypt {
501                peer: PublicKey::parse(pk_hex)?,
502                text: text.clone(),
503            }),
504            (Method::Nip04Decrypt, [pk_hex, ciphertext]) => Ok(Self::Nip04Decrypt {
505                peer: PublicKey::parse(pk_hex)?,
506                ciphertext: ciphertext.clone(),
507            }),
508            (Method::Nip44Decrypt, [pk_hex, ciphertext]) => Ok(Self::Nip44Decrypt {
509                peer: PublicKey::parse(pk_hex)?,
510                ciphertext: ciphertext.clone(),
511            }),
512            (Method::Ping, []) => Ok(Self::Ping),
513            (Method::SwitchRelays, []) => Ok(Self::SwitchRelays),
514            // Arity-mismatch fallbacks, grouped by required param count
515            // (clippy::match_same_arms refuses two arms that produce
516            // structurally identical bodies).
517            (Method::GetPublicKey | Method::Ping | Method::SwitchRelays, _) => {
518                Err(invalid_param_length(method, 0, params.len()))
519            }
520            // `connect` accepts 1, 2, or 3 positional args; the
521            // catch-all here only fires for 0 or 4+. We report the
522            // canonical 1-arg form for both `sign_event` and
523            // `connect` to keep the diagnostic surface stable.
524            (Method::SignEvent | Method::Connect, _) => {
525                Err(invalid_param_length(method, 1, params.len()))
526            }
527            (
528                Method::Nip04Encrypt
529                | Method::Nip04Decrypt
530                | Method::Nip44Encrypt
531                | Method::Nip44Decrypt,
532                _,
533            ) => Err(invalid_param_length(method, 2, params.len())),
534        }
535    }
536}
537
538const fn invalid_param_length(method: Method, expected: usize, actual: usize) -> Nip46Error {
539    Nip46Error::InvalidParamLength {
540        method,
541        expected,
542        actual,
543    }
544}
545
546/// Typed response payload.
547///
548/// `result == None && error == Some(_)` is signaled by [`Response`].
549/// `ResponseResult` only models the `success` / `auth_url` / `error`
550/// tagged variants the spec defines for the `result` slot.
551#[derive(Debug, Clone, PartialEq, Eq)]
552#[non_exhaustive]
553pub enum ResponseResult {
554    /// `connect` accepted via the `bunker://` flow.
555    Ack,
556    /// `connect` accepted via the `nostrconnect://` flow; the signer
557    /// echoes the secret from the URI back to prove possession of the
558    /// matching key.
559    ConnectSecret(String),
560    /// User's pubkey.
561    GetPublicKey(PublicKey),
562    /// Signed event.
563    SignEvent(Box<Event>),
564    /// NIP-04 ciphertext.
565    Nip04Encrypt(String),
566    /// NIP-04 plaintext.
567    Nip04Decrypt(String),
568    /// NIP-44 ciphertext.
569    Nip44Encrypt(String),
570    /// NIP-44 plaintext.
571    Nip44Decrypt(String),
572    /// Liveness probe response.
573    Pong,
574    /// `switch_relays` reply: either the signer's updated relay set,
575    /// or `None` meaning "no change" (spec line 108 `... OR null`).
576    SwitchRelays(Option<Vec<RelayUrl>>),
577    /// The signer needs the user to complete an out-of-band step
578    /// (typically open a URL); the URL travels in the `error` slot per
579    /// spec.
580    AuthUrl,
581    /// An error string is present in `error`.
582    Error,
583}
584
585impl ResponseResult {
586    /// Decode a wire `result` string (already JSON-decoded out of the
587    /// outer envelope) given the originating `method`.
588    ///
589    /// # Errors
590    ///
591    /// Returns [`Nip46Error::Json`] / [`Nip46Error::PublicKey`] /
592    /// [`Nip46Error::Event`] when the body is malformed for the
593    /// requested method, and [`Nip46Error::UnexpectedResponse`] for
594    /// `ping` if the literal payload isn't `"pong"`.
595    pub fn from_wire(method: Method, result: &str) -> Result<Self, Nip46Error> {
596        // The two universal sentinels first; both can be returned by
597        // any method.
598        match result {
599            "auth_url" => return Ok(Self::AuthUrl),
600            "error" => return Ok(Self::Error),
601            _ => {}
602        }
603        match method {
604            Method::Connect => {
605                if result == "ack" {
606                    Ok(Self::Ack)
607                } else {
608                    Ok(Self::ConnectSecret(result.to_owned()))
609                }
610            }
611            Method::GetPublicKey => Ok(Self::GetPublicKey(PublicKey::parse(result)?)),
612            Method::SignEvent => Ok(Self::SignEvent(Box::new(Event::from_json(result)?))),
613            Method::Nip04Encrypt => Ok(Self::Nip04Encrypt(result.to_owned())),
614            Method::Nip04Decrypt => Ok(Self::Nip04Decrypt(result.to_owned())),
615            Method::Nip44Encrypt => Ok(Self::Nip44Encrypt(result.to_owned())),
616            Method::Nip44Decrypt => Ok(Self::Nip44Decrypt(result.to_owned())),
617            Method::Ping => {
618                if result == "pong" {
619                    Ok(Self::Pong)
620                } else {
621                    Err(Nip46Error::UnexpectedResponse {
622                        method,
623                        expected: "pong",
624                        received: result.to_owned(),
625                    })
626                }
627            }
628            Method::SwitchRelays => {
629                let trimmed = result.trim();
630                if trimmed == "null" {
631                    return Ok(Self::SwitchRelays(None));
632                }
633                let raw: Vec<String> = serde_json::from_str(trimmed)
634                    .map_err(|_| Nip46Error::InvalidSwitchRelaysPayload)?;
635                let mut relays = Vec::with_capacity(raw.len());
636                for url in raw {
637                    relays.push(RelayUrl::parse(&url)?);
638                }
639                Ok(Self::SwitchRelays(Some(relays)))
640            }
641        }
642    }
643
644    /// Wire encoding of the result (string placed in the JSON `result`
645    /// field).
646    ///
647    /// `SignEvent` produces a JSON-encoded event, `SwitchRelays`
648    /// produces a JSON-encoded array (or the literal `null`), every
649    /// other variant is a single token.
650    #[must_use]
651    pub fn to_wire(&self) -> String {
652        match self {
653            Self::Ack => "ack".to_owned(),
654            Self::ConnectSecret(s)
655            | Self::Nip04Encrypt(s)
656            | Self::Nip04Decrypt(s)
657            | Self::Nip44Encrypt(s)
658            | Self::Nip44Decrypt(s) => s.clone(),
659            Self::GetPublicKey(pk) => pk.to_hex(),
660            Self::SignEvent(ev) => ev.try_to_json().unwrap_or_default(),
661            Self::Pong => "pong".to_owned(),
662            Self::SwitchRelays(None) => "null".to_owned(),
663            Self::SwitchRelays(Some(relays)) => {
664                let urls: Vec<&str> = relays.iter().map(RelayUrl::as_str).collect();
665                serde_json::to_string(&urls).unwrap_or_else(|_| "null".to_owned())
666            }
667            Self::AuthUrl => "auth_url".to_owned(),
668            Self::Error => "error".to_owned(),
669        }
670    }
671
672    /// `true` if this is the `auth_url` sentinel.
673    #[must_use]
674    pub const fn is_auth_url(&self) -> bool {
675        matches!(self, Self::AuthUrl)
676    }
677
678    /// `true` if this is the `error` sentinel.
679    #[must_use]
680    pub const fn is_error(&self) -> bool {
681        matches!(self, Self::Error)
682    }
683}
684
685/// Decoded response (result + optional error).
686///
687/// At most one of `result` / `error` is meaningful at a time; the
688/// other is `None`. The wire format always carries both fields, with
689/// `null` for the slot that doesn't apply.
690#[derive(Debug, Clone, PartialEq, Eq)]
691#[non_exhaustive]
692pub struct Response {
693    /// Decoded `result` slot.
694    pub result: Option<ResponseResult>,
695    /// Optional human-readable error message (carries the auth URL
696    /// when `result == Some(AuthUrl)`).
697    pub error: Option<String>,
698}
699
700impl Response {
701    /// Build a successful response.
702    #[must_use]
703    pub const fn with_result(result: ResponseResult) -> Self {
704        Self {
705            result: Some(result),
706            error: None,
707        }
708    }
709
710    /// Build an error response.
711    #[must_use]
712    pub fn with_error(error: impl Into<String>) -> Self {
713        Self {
714            result: None,
715            error: Some(error.into()),
716        }
717    }
718
719    /// Decode a wire `(result?, error?)` pair given the originating method.
720    ///
721    /// # Errors
722    ///
723    /// Forwards every failure from [`ResponseResult::from_wire`].
724    pub fn from_wire(
725        method: Method,
726        result: Option<&str>,
727        error: Option<String>,
728    ) -> Result<Self, Nip46Error> {
729        let decoded = match result {
730            Some(s) => Some(ResponseResult::from_wire(method, s)?),
731            None => None,
732        };
733        Ok(Self {
734            result: decoded,
735            error,
736        })
737    }
738}
739
740/// Wire envelope: a request *or* a response.
741#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742#[serde(untagged)]
743#[non_exhaustive]
744pub enum Message {
745    /// Request frame.
746    Request {
747        /// Caller-chosen identifier; the response carries the same
748        /// `id` so the requester can match them up.
749        id: String,
750        /// Method to invoke.
751        method: Method,
752        /// Wire-format params (each element pre-stringified).
753        params: Vec<String>,
754    },
755    /// Response frame.
756    Response {
757        /// Matches the `id` of the originating [`Self::Request`].
758        id: String,
759        /// Result slot — always present in the JSON, possibly
760        /// `null`.
761        result: Option<String>,
762        /// Error slot — always present in the JSON, possibly
763        /// `null`.
764        error: Option<String>,
765    },
766}
767
768impl Message {
769    /// Build a request envelope from a typed [`Request`] and an
770    /// arbitrary id (typically a random `u32` or a UUID).
771    #[must_use]
772    pub fn request(id: impl Into<String>, request: &Request) -> Self {
773        Self::Request {
774            id: id.into(),
775            method: request.method(),
776            params: request.params(),
777        }
778    }
779
780    /// Build a response envelope from a typed [`Response`] and the
781    /// originating request id.
782    #[must_use]
783    pub fn response(id: impl Into<String>, response: Response) -> Self {
784        Self::Response {
785            id: id.into(),
786            result: response.result.as_ref().map(ResponseResult::to_wire),
787            error: response.error,
788        }
789    }
790
791    /// Borrow the envelope id (matches across request/response).
792    #[must_use]
793    pub fn id(&self) -> &str {
794        match self {
795            Self::Request { id, .. } | Self::Response { id, .. } => id,
796        }
797    }
798
799    /// Decode a [`Self::Request`] envelope into a typed [`Request`].
800    ///
801    /// # Errors
802    ///
803    /// Returns [`Nip46Error::WrongMessageKind`] when the envelope is
804    /// actually a response, and forwards every parse error from
805    /// [`Request::from_wire`].
806    pub fn into_request(self) -> Result<Request, Nip46Error> {
807        match self {
808            Self::Request { method, params, .. } => Request::from_wire(method, &params),
809            Self::Response { .. } => Err(Nip46Error::WrongMessageKind(
810                "expected Request, got Response",
811            )),
812        }
813    }
814
815    /// Decode a [`Self::Response`] envelope into a typed [`Response`].
816    ///
817    /// # Errors
818    ///
819    /// Returns [`Nip46Error::WrongMessageKind`] when the envelope is
820    /// actually a request, and forwards every parse error from
821    /// [`Response::from_wire`].
822    pub fn into_response(self, method: Method) -> Result<Response, Nip46Error> {
823        match self {
824            Self::Response { result, error, .. } => {
825                Response::from_wire(method, result.as_deref(), error)
826            }
827            Self::Request { .. } => Err(Nip46Error::WrongMessageKind(
828                "expected Response, got Request",
829            )),
830        }
831    }
832}
833
834// `JsonUtil` is auto-implemented for every `Serialize + DeserializeOwned`
835// type via the blanket `impl<T> JsonUtil for T` in `crate::util::json`,
836// so `Message::try_to_json` and `Message::from_json` work without an
837// explicit impl block.
838
839/// Connection metadata advertised by a `nostrconnect://` URI.
840///
841/// The signer renders these fields when prompting the user to approve
842/// the connection.
843#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
844#[non_exhaustive]
845pub struct Metadata {
846    /// Human-readable app name.
847    pub name: String,
848    /// Optional homepage URL.
849    #[serde(skip_serializing_if = "Option::is_none", default)]
850    pub url: Option<String>,
851    /// Optional one-line description.
852    #[serde(skip_serializing_if = "Option::is_none", default)]
853    pub description: Option<String>,
854    /// Optional list of icon URLs (for the signer's UI).
855    #[serde(skip_serializing_if = "Option::is_none", default)]
856    pub icons: Option<Vec<String>>,
857}
858
859impl Metadata {
860    /// Construct minimal metadata with just an app name.
861    #[must_use]
862    pub fn new(name: impl Into<String>) -> Self {
863        Self {
864            name: name.into(),
865            url: None,
866            description: None,
867            icons: None,
868        }
869    }
870}
871
872// `JsonUtil` for `Metadata` comes from the blanket impl (see comment
873// on `Message`).
874
875/// Connection URI: `bunker://` or `nostrconnect://`.
876#[derive(Debug, Clone, PartialEq, Eq)]
877#[non_exhaustive]
878pub enum Uri {
879    /// `bunker://<remote_signer_pubkey>?relay=…&relay=…&secret=…`
880    ///
881    /// The signer publishes this URI; the client dials the listed
882    /// relays and addresses the signer's pubkey directly.
883    Bunker {
884        /// Remote signer's pubkey (the one the client encrypts to).
885        remote_signer_public_key: PublicKey,
886        /// Relays the signer is listening on (in preference order).
887        relays: Vec<RelayUrl>,
888        /// Optional one-time secret the client must echo to prove the
889        /// URI was used by the intended party.
890        secret: Option<String>,
891    },
892    /// `nostrconnect://<client_pubkey>?relay=…&metadata=…&secret=…&perms=…`
893    ///
894    /// The client publishes this URI (typically as a QR code); the
895    /// signer dials the listed relays and addresses the client's
896    /// pubkey. NIP-46 makes the `secret` field **mandatory** in this
897    /// flow — the signer must echo it back inside the `connect`
898    /// response so the client can rule out an MITM.
899    Client {
900        /// App's session pubkey.
901        public_key: PublicKey,
902        /// Relays both sides will rendezvous on.
903        relays: Vec<RelayUrl>,
904        /// App identity metadata.
905        metadata: Metadata,
906        /// Anti-spoofing secret (mandatory).
907        secret: String,
908        /// Optional requested permissions (NIP-46 § "Requested
909        /// permissions"); empty when omitted from the URI.
910        perms: Vec<Permission>,
911    },
912}
913
914impl Uri {
915    /// Parse a `bunker://` or `nostrconnect://` URI.
916    ///
917    /// # Errors
918    ///
919    /// See [`Nip46Error`].
920    pub fn parse(uri: &str) -> Result<Self, Nip46Error> {
921        let parsed = Url::parse(uri)?;
922        let host = parsed
923            .host_str()
924            .ok_or(Nip46Error::MalformedUri("missing pubkey host"))?;
925        let public_key = PublicKey::parse(host)?;
926
927        let mut relays: Vec<RelayUrl> = Vec::new();
928        let mut secret: Option<String> = None;
929        let mut metadata: Option<Metadata> = None;
930        let mut perms: Vec<Permission> = Vec::new();
931        for (key, value) in parsed.query_pairs() {
932            match key.as_ref() {
933                "relay" => relays.push(RelayUrl::parse(value.as_ref())?),
934                "secret" => secret = Some(value.into_owned()),
935                "metadata" => metadata = Some(Metadata::from_json(value.as_ref())?),
936                "perms" => perms = Permission::split(value.as_ref()),
937                // Forward-compat: silently drop unknown query
938                // parameters (vendor-specific keys we have no model for).
939                _ => {}
940            }
941        }
942
943        match parsed.scheme() {
944            URI_SCHEME_BUNKER => Ok(Self::Bunker {
945                remote_signer_public_key: public_key,
946                relays,
947                secret,
948            }),
949            URI_SCHEME_CLIENT => {
950                let secret = secret.ok_or(Nip46Error::MalformedUri(
951                    "`nostrconnect://` URIs require the `secret` query parameter",
952                ))?;
953                let metadata = metadata.ok_or(Nip46Error::MalformedUri(
954                    "`nostrconnect://` URIs require the `metadata` query parameter",
955                ))?;
956                Ok(Self::Client {
957                    public_key,
958                    relays,
959                    metadata,
960                    secret,
961                    perms,
962                })
963            }
964            other => Err(Nip46Error::UnknownUriScheme(other.to_owned())),
965        }
966    }
967
968    /// `true` if this is a `bunker://` URI.
969    #[must_use]
970    pub const fn is_bunker(&self) -> bool {
971        matches!(self, Self::Bunker { .. })
972    }
973
974    /// Borrow the relay set the URI advertises.
975    #[must_use]
976    pub fn relays(&self) -> &[RelayUrl] {
977        match self {
978            Self::Bunker { relays, .. } | Self::Client { relays, .. } => relays,
979        }
980    }
981
982    /// Borrow the secret slot (always `Some` for `Client`, optional
983    /// for `Bunker`).
984    #[must_use]
985    pub fn secret(&self) -> Option<&str> {
986        match self {
987            Self::Bunker { secret, .. } => secret.as_deref(),
988            Self::Client { secret, .. } => Some(secret),
989        }
990    }
991}
992
993impl FromStr for Uri {
994    type Err = Nip46Error;
995
996    fn from_str(s: &str) -> Result<Self, Self::Err> {
997        Self::parse(s)
998    }
999}
1000
1001impl fmt::Display for Uri {
1002    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1003        match self {
1004            Self::Bunker {
1005                remote_signer_public_key,
1006                relays,
1007                secret,
1008            } => {
1009                write!(f, "{URI_SCHEME_BUNKER}://{remote_signer_public_key}")?;
1010                write_query(f, relays, secret.as_deref(), None, &[])
1011            }
1012            Self::Client {
1013                public_key,
1014                relays,
1015                metadata,
1016                secret,
1017                perms,
1018            } => {
1019                write!(f, "{URI_SCHEME_CLIENT}://{public_key}")?;
1020                let metadata_json = metadata.try_to_json().unwrap_or_default();
1021                write_query(f, relays, Some(secret), Some(&metadata_json), perms)
1022            }
1023        }
1024    }
1025}
1026
1027fn write_query(
1028    out: &mut fmt::Formatter<'_>,
1029    relays: &[RelayUrl],
1030    secret: Option<&str>,
1031    metadata_json: Option<&str>,
1032    perms: &[Permission],
1033) -> fmt::Result {
1034    let mut first = true;
1035    let mut emit = |sink: &mut fmt::Formatter<'_>, key: &str, value: &str| -> fmt::Result {
1036        sink.write_str(if first { "?" } else { "&" })?;
1037        first = false;
1038        write!(sink, "{key}={}", url_encode(value))
1039    };
1040    for relay in relays {
1041        emit(out, "relay", relay.as_str())?;
1042    }
1043    if let Some(meta) = metadata_json {
1044        emit(out, "metadata", meta)?;
1045    }
1046    if let Some(s) = secret {
1047        emit(out, "secret", s)?;
1048    }
1049    if !perms.is_empty() {
1050        emit(out, "perms", &Permission::join(perms))?;
1051    }
1052    Ok(())
1053}
1054
1055/// Minimal percent-encoding for query-string values. Encodes the
1056/// reserved `:?#[]@!$&'()*+,;=` plus `%` and whitespace; everything
1057/// else passes through unchanged. This is a tighter set than the full
1058/// RFC-3986 spec but is sufficient for the values we ever produce
1059/// (relay URLs, base64, JSON).
1060fn url_encode(input: &str) -> String {
1061    let mut out = String::with_capacity(input.len());
1062    for byte in input.bytes() {
1063        let preserve =
1064            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'/' | b':');
1065        if preserve {
1066            out.push(byte as char);
1067        } else {
1068            out.push('%');
1069            out.push(hex_nibble(byte >> 4));
1070            out.push(hex_nibble(byte & 0x0f));
1071        }
1072    }
1073    out
1074}
1075
1076const fn hex_nibble(n: u8) -> char {
1077    match n {
1078        0..=9 => (b'0' + n) as char,
1079        10..=15 => (b'A' + (n - 10)) as char,
1080        _ => '0',
1081    }
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086    use super::*;
1087    use crate::Keys;
1088
1089    fn fixture_pk() -> PublicKey {
1090        // Deterministic fixture; the exact value doesn't matter for
1091        // the round-trip / parse tests below.
1092        *Keys::parse("0000000000000000000000000000000000000000000000000000000000000003")
1093            .unwrap()
1094            .public_key()
1095    }
1096
1097    #[test]
1098    fn method_round_trips_through_str() {
1099        for method in [
1100            Method::Connect,
1101            Method::GetPublicKey,
1102            Method::SignEvent,
1103            Method::Nip04Encrypt,
1104            Method::Nip04Decrypt,
1105            Method::Nip44Encrypt,
1106            Method::Nip44Decrypt,
1107            Method::Ping,
1108            Method::SwitchRelays,
1109        ] {
1110            let s = method.as_str();
1111            let parsed: Method = s.parse().unwrap();
1112            assert_eq!(parsed, method);
1113        }
1114    }
1115
1116    #[test]
1117    fn unknown_method_is_rejected() {
1118        let err: Nip46Error = "open_my_drone".parse::<Method>().unwrap_err();
1119        assert!(matches!(err, Nip46Error::UnsupportedMethod(s) if s == "open_my_drone"));
1120    }
1121
1122    #[test]
1123    fn request_round_trip_through_wire_params() {
1124        let pk = fixture_pk();
1125        let cases: Vec<Request> = vec![
1126            Request::Connect {
1127                remote_signer_public_key: pk,
1128                secret: Some("hunter2".to_owned()),
1129                perms: None,
1130            },
1131            Request::Connect {
1132                remote_signer_public_key: pk,
1133                secret: None,
1134                perms: None,
1135            },
1136            Request::Connect {
1137                remote_signer_public_key: pk,
1138                secret: Some("hunter2".to_owned()),
1139                perms: Some(vec![
1140                    Permission::Method(Method::Nip44Encrypt),
1141                    Permission::SignEventKind(Kind::TEXT_NOTE),
1142                ]),
1143            },
1144            Request::Connect {
1145                remote_signer_public_key: pk,
1146                secret: None,
1147                perms: Some(vec![Permission::Method(Method::GetPublicKey)]),
1148            },
1149            Request::Connect {
1150                remote_signer_public_key: pk,
1151                secret: None,
1152                perms: Some(Vec::new()),
1153            },
1154            Request::GetPublicKey,
1155            Request::Nip04Encrypt {
1156                peer: pk,
1157                text: "hi".to_owned(),
1158            },
1159            Request::Nip04Decrypt {
1160                peer: pk,
1161                ciphertext: "AAAA?iv=AAAA".to_owned(),
1162            },
1163            Request::Nip44Encrypt {
1164                peer: pk,
1165                text: "hello".to_owned(),
1166            },
1167            Request::Nip44Decrypt {
1168                peer: pk,
1169                ciphertext: "AgAB...".to_owned(),
1170            },
1171            Request::Ping,
1172            Request::SwitchRelays,
1173        ];
1174
1175        for req in cases {
1176            let method = req.method();
1177            let params = req.params();
1178            let recovered = Request::from_wire(method, &params).unwrap();
1179            assert_eq!(recovered, req);
1180        }
1181    }
1182
1183    #[test]
1184    fn request_param_count_validation() {
1185        let pk = fixture_pk();
1186        let bad = Request::from_wire(Method::Nip04Encrypt, &[pk.to_hex()]).unwrap_err();
1187        assert!(matches!(
1188            bad,
1189            Nip46Error::InvalidParamLength {
1190                method: Method::Nip04Encrypt,
1191                expected: 2,
1192                actual: 1,
1193            }
1194        ));
1195    }
1196
1197    #[test]
1198    fn response_decode_handles_universal_sentinels() {
1199        let auth = ResponseResult::from_wire(Method::SignEvent, "auth_url").unwrap();
1200        assert!(auth.is_auth_url());
1201        let err = ResponseResult::from_wire(Method::Connect, "error").unwrap();
1202        assert!(err.is_error());
1203    }
1204
1205    #[test]
1206    fn response_decode_for_each_method() {
1207        let pk = fixture_pk();
1208        // GetPublicKey
1209        match ResponseResult::from_wire(Method::GetPublicKey, &pk.to_hex()).unwrap() {
1210            ResponseResult::GetPublicKey(decoded) => assert_eq!(decoded, pk),
1211            other => panic!("unexpected variant: {other:?}"),
1212        }
1213        // Connect with literal "ack"
1214        let ack = ResponseResult::from_wire(Method::Connect, "ack").unwrap();
1215        assert!(matches!(ack, ResponseResult::Ack));
1216        // Connect with custom secret
1217        let secret = ResponseResult::from_wire(Method::Connect, "abcdef0123").unwrap();
1218        assert!(matches!(secret, ResponseResult::ConnectSecret(s) if s == "abcdef0123"));
1219        // Ping happy path
1220        let pong = ResponseResult::from_wire(Method::Ping, "pong").unwrap();
1221        assert!(matches!(pong, ResponseResult::Pong));
1222        // Ping unhappy path
1223        let err = ResponseResult::from_wire(Method::Ping, "ping").unwrap_err();
1224        assert!(matches!(err, Nip46Error::UnexpectedResponse { .. }));
1225    }
1226
1227    #[test]
1228    fn message_request_round_trips_through_json() {
1229        let pk = fixture_pk();
1230        let request = Request::Nip44Encrypt {
1231            peer: pk,
1232            text: "hello".to_owned(),
1233        };
1234        let msg = Message::request("req-1", &request);
1235        let json = msg.try_to_json().unwrap();
1236        let recovered = Message::from_json(&json).unwrap();
1237        assert_eq!(recovered.id(), "req-1");
1238        let recovered_req = recovered.into_request().unwrap();
1239        assert_eq!(recovered_req, request);
1240    }
1241
1242    #[test]
1243    fn message_response_round_trips_through_json() {
1244        let response = Response::with_result(ResponseResult::Pong);
1245        let msg = Message::response("ping-42", response);
1246        let json = msg.try_to_json().unwrap();
1247        let recovered = Message::from_json(&json).unwrap();
1248        assert_eq!(recovered.id(), "ping-42");
1249        let recovered_resp = recovered.into_response(Method::Ping).unwrap();
1250        assert!(matches!(recovered_resp.result, Some(ResponseResult::Pong)));
1251        assert!(recovered_resp.error.is_none());
1252    }
1253
1254    #[test]
1255    fn into_request_rejects_response_envelopes() {
1256        let msg = Message::Response {
1257            id: "x".into(),
1258            result: Some("ack".into()),
1259            error: None,
1260        };
1261        let err = msg.into_request().unwrap_err();
1262        assert!(matches!(err, Nip46Error::WrongMessageKind(_)));
1263    }
1264
1265    #[test]
1266    fn bunker_uri_round_trip() {
1267        let pk = fixture_pk();
1268        let original = format!(
1269            "bunker://{}?relay=wss%3A%2F%2Frelay.example%2F&secret=hunter2",
1270            pk.to_hex(),
1271        );
1272        let parsed = Uri::parse(&original).unwrap();
1273        match &parsed {
1274            Uri::Bunker {
1275                remote_signer_public_key,
1276                relays,
1277                secret,
1278            } => {
1279                assert_eq!(*remote_signer_public_key, pk);
1280                assert_eq!(relays.len(), 1);
1281                assert_eq!(relays[0].as_str(), "wss://relay.example/");
1282                assert_eq!(secret.as_deref(), Some("hunter2"));
1283            }
1284            other => panic!("unexpected variant: {other:?}"),
1285        }
1286        // Reformatted output is parseable again — we don't compare the
1287        // strings byte-for-byte because the percent-encoding may
1288        // differ (URLs are unique per character set, not per byte
1289        // representation).
1290        let rendered = parsed.to_string();
1291        let reparsed = Uri::parse(&rendered).unwrap();
1292        assert_eq!(reparsed, parsed);
1293    }
1294
1295    #[test]
1296    fn nostrconnect_uri_requires_secret() {
1297        let pk = fixture_pk();
1298        let bad = format!(
1299            "nostrconnect://{}?relay=wss%3A%2F%2Frelay.example%2F&metadata=%7B%22name%22%3A%22demo%22%7D",
1300            pk.to_hex(),
1301        );
1302        let err = Uri::parse(&bad).unwrap_err();
1303        assert!(matches!(err, Nip46Error::MalformedUri(_)));
1304    }
1305
1306    #[test]
1307    fn nostrconnect_uri_round_trip() {
1308        let pk = fixture_pk();
1309        let metadata = Metadata::new("demo");
1310        let original = Uri::Client {
1311            public_key: pk,
1312            relays: vec![RelayUrl::parse("wss://relay.example/").unwrap()],
1313            metadata: metadata.clone(),
1314            secret: "anti-mitm".into(),
1315            perms: Vec::new(),
1316        };
1317        let rendered = original.to_string();
1318        let reparsed = Uri::parse(&rendered).unwrap();
1319        assert_eq!(reparsed, original);
1320        assert_eq!(reparsed.secret(), Some("anti-mitm"));
1321        match reparsed {
1322            Uri::Client {
1323                metadata: parsed_meta,
1324                ..
1325            } => assert_eq!(parsed_meta, metadata),
1326            other => panic!("unexpected variant: {other:?}"),
1327        }
1328    }
1329
1330    #[test]
1331    fn unknown_scheme_is_rejected() {
1332        let pk = fixture_pk();
1333        let err = Uri::parse(&format!("nip46://{}", pk.to_hex())).unwrap_err();
1334        assert!(matches!(err, Nip46Error::UnknownUriScheme(s) if s == "nip46"));
1335    }
1336
1337    #[test]
1338    fn permission_token_round_trips() {
1339        // Bare method.
1340        let bare: Permission = "get_public_key".parse().unwrap();
1341        assert_eq!(bare, Permission::Method(Method::GetPublicKey));
1342        assert_eq!(bare.to_wire(), "get_public_key");
1343        // sign_event:<kind>
1344        let kinded: Permission = "sign_event:4".parse().unwrap();
1345        assert_eq!(kinded, Permission::SignEventKind(Kind::new(4)));
1346        assert_eq!(kinded.to_wire(), "sign_event:4");
1347        // Vendor / future-spec passthrough preserves the verbatim wire.
1348        let vendor: Permission = "weird_vendor:opt=1".parse().unwrap();
1349        assert_eq!(vendor, Permission::Other("weird_vendor:opt=1".to_owned()));
1350        assert_eq!(vendor.to_wire(), "weird_vendor:opt=1");
1351        // sign_event with a non-numeric tail falls into Other rather than
1352        // a parse error — spec leaves param shapes extensible.
1353        let extensible: Permission = "sign_event:any".parse().unwrap();
1354        assert_eq!(extensible, Permission::Other("sign_event:any".to_owned()));
1355    }
1356
1357    #[test]
1358    fn permission_list_round_trips_via_join_split() {
1359        // Mirrors the spec example string at NIP-46 line 112.
1360        let perms = vec![
1361            Permission::Method(Method::Nip44Encrypt),
1362            Permission::SignEventKind(Kind::new(4)),
1363        ];
1364        let joined = Permission::join(&perms);
1365        assert_eq!(joined, "nip44_encrypt,sign_event:4");
1366        let parsed = Permission::split(&joined);
1367        assert_eq!(parsed, perms);
1368        // Empty string \u2192 empty vec; whitespace tolerated.
1369        assert!(Permission::split("").is_empty());
1370        assert_eq!(
1371            Permission::split(" ping , sign_event:1 "),
1372            vec![
1373                Permission::Method(Method::Ping),
1374                Permission::SignEventKind(Kind::TEXT_NOTE),
1375            ],
1376        );
1377    }
1378
1379    #[test]
1380    fn connect_request_with_perms_emits_positional_layout() {
1381        let pk = fixture_pk();
1382        // perms-only: position 1 (secret) is a placeholder empty string
1383        // so position 2 (perms) keeps its index.
1384        let req = Request::Connect {
1385            remote_signer_public_key: pk,
1386            secret: None,
1387            perms: Some(vec![Permission::Method(Method::GetPublicKey)]),
1388        };
1389        let params = req.params();
1390        assert_eq!(params.len(), 3);
1391        assert_eq!(params[0], pk.to_hex());
1392        assert_eq!(params[1], "");
1393        assert_eq!(params[2], "get_public_key");
1394        let recovered = Request::from_wire(Method::Connect, &params).unwrap();
1395        assert_eq!(recovered, req);
1396    }
1397
1398    #[test]
1399    fn switch_relays_response_round_trips_through_wire() {
1400        // null branch: spec line 108 explicit `OR null`.
1401        let null_value = ResponseResult::SwitchRelays(None);
1402        assert_eq!(null_value.to_wire(), "null");
1403        let null_recovered = ResponseResult::from_wire(Method::SwitchRelays, "null").unwrap();
1404        assert_eq!(null_recovered, null_value);
1405
1406        // empty-array branch.
1407        let empty_value = ResponseResult::SwitchRelays(Some(Vec::new()));
1408        let empty_wire = empty_value.to_wire();
1409        assert_eq!(empty_wire, "[]");
1410        let empty_recovered = ResponseResult::from_wire(Method::SwitchRelays, &empty_wire).unwrap();
1411        assert_eq!(empty_recovered, empty_value);
1412
1413        // non-empty array branch.
1414        let relays = vec![
1415            RelayUrl::parse("wss://relay.one/").unwrap(),
1416            RelayUrl::parse("wss://relay.two/").unwrap(),
1417        ];
1418        let populated = ResponseResult::SwitchRelays(Some(relays));
1419        let populated_wire = populated.to_wire();
1420        let populated_recovered =
1421            ResponseResult::from_wire(Method::SwitchRelays, &populated_wire).unwrap();
1422        assert_eq!(populated_recovered, populated);
1423
1424        // malformed JSON \u2192 dedicated error variant.
1425        let err =
1426            ResponseResult::from_wire(Method::SwitchRelays, "not-json").expect_err("must reject");
1427        assert!(matches!(err, Nip46Error::InvalidSwitchRelaysPayload));
1428    }
1429
1430    #[test]
1431    fn switch_relays_request_envelope_round_trips_through_json() {
1432        let msg = Message::request("sw-1", &Request::SwitchRelays);
1433        let json = msg.try_to_json().unwrap();
1434        let recovered = Message::from_json(&json).unwrap();
1435        assert_eq!(recovered.id(), "sw-1");
1436        let req = recovered.into_request().unwrap();
1437        assert_eq!(req, Request::SwitchRelays);
1438    }
1439
1440    #[test]
1441    fn nostrconnect_uri_carries_perms_round_trip() {
1442        let pk = fixture_pk();
1443        let metadata = Metadata::new("demo");
1444        let original = Uri::Client {
1445            public_key: pk,
1446            relays: vec![RelayUrl::parse("wss://relay.example/").unwrap()],
1447            metadata,
1448            secret: "anti-mitm".into(),
1449            perms: vec![
1450                Permission::Method(Method::Nip44Encrypt),
1451                Permission::Method(Method::Nip44Decrypt),
1452                Permission::SignEventKind(Kind::new(13)),
1453                Permission::SignEventKind(Kind::new(14)),
1454                Permission::SignEventKind(Kind::new(1059)),
1455            ],
1456        };
1457        let rendered = original.to_string();
1458        assert!(rendered.contains("perms="));
1459        let reparsed = Uri::parse(&rendered).unwrap();
1460        assert_eq!(reparsed, original);
1461    }
1462}