Skip to main content

nula_core/nips/nip19/
mod.rs

1//! [NIP-19] bech32 encoding for Nostr entities.
2//!
3//! NIP-19 covers two families of identifiers:
4//!
5//! 1. **Plain identifiers** — a single 32-byte payload labelled by its HRP:
6//!    `npub` ([`PublicKey`]), `nsec` ([`SecretKey`]), and `note`
7//!    ([`EventId`]).
8//! 2. **Compound identifiers** — TLV-encoded structures: `nprofile`
9//!    ([`Nip19Profile`]), `nevent` ([`Nip19Event`]), and `naddr`
10//!    ([`Nip19Coordinate`]).
11//!
12//! Use the [`ToBech32`] / [`FromBech32`] traits when you want compile-time
13//! certainty about the HRP, or [`Nip19Entity`] when you only know that the
14//! input is *some* NIP-19 string (for example, a value pasted by an end
15//! user).
16//!
17//! # Wire-format placement
18//!
19//! Per **NIP-19 §Notes**, bech32-encoded entities are **only** for human
20//! display, copy-paste, and QR codes. They MUST NOT appear inside:
21//!
22//! - the `pubkey` / `id` / `tags` fields of a [`crate::Event`] (NIP-01),
23//! - the `ids` / `authors` fields of a [`crate::Filter`],
24//! - the `#e` / `#p` filter values, or
25//! - NIP-05 JSON responses.
26//!
27//! These places strictly require the underlying lowercase hex form. The
28//! Nip19* types in this module always decode back to those primitive
29//! types ([`PublicKey`], [`EventId`], [`crate::event::Coordinate`]) so
30//! callers should pass *those* downstream rather than the bech32
31//! strings.
32//!
33//! [NIP-19]: https://github.com/nostr-protocol/nips/blob/master/19.md
34//! [`PublicKey`]: crate::PublicKey
35//! [`SecretKey`]: crate::SecretKey
36//! [`EventId`]: crate::EventId
37
38pub mod coordinate;
39pub mod event;
40pub mod hrp;
41pub mod profile;
42pub mod tlv;
43
44use std::str;
45use std::str::Utf8Error;
46
47use bech32::Bech32;
48use bech32::primitives::decode::{CheckedHrpstring, CheckedHrpstringError};
49use thiserror::Error;
50
51pub use self::coordinate::Nip19Coordinate;
52pub use self::event::Nip19Event;
53pub use self::profile::Nip19Profile;
54pub use self::tlv::{Record as TlvRecord, TlvError};
55use crate::event::{EventId, EventIdError, Kind};
56use crate::key::{PublicKey, PublicKeyError, SecretKey, SecretKeyError};
57use crate::types::{RelayUrl, RelayUrlError};
58
59/// Maximum accepted length, in characters, of any NIP-19 bech32 string.
60///
61/// NIP-19 §Notes recommends limiting bech32 strings to 5000 characters; we
62/// turn that recommendation into a hard cap so untrusted input cannot
63/// trigger pathological allocations during decoding.
64pub const MAX_NIP19_LENGTH: usize = 5000;
65
66/// Error produced when encoding a value to its NIP-19 representation.
67#[derive(Debug, Error)]
68#[non_exhaustive]
69pub enum ToBech32Error {
70    /// `bech32` rejected the encoding (typically: HRP + data is too long for
71    /// the underlying checksum algorithm).
72    #[error("bech32 encoding failed: {0}")]
73    Encode(#[from] bech32::EncodeError),
74    /// A TLV value exceeded its 255-byte cap (relay URL, identifier, …).
75    #[error(transparent)]
76    Tlv(#[from] TlvError),
77}
78
79/// Error produced when decoding a NIP-19 string.
80#[derive(Debug, Error)]
81#[non_exhaustive]
82pub enum FromBech32Error {
83    /// The input exceeded [`MAX_NIP19_LENGTH`].
84    #[error("NIP-19 string is too long: {len} characters (max {max})")]
85    TooLong {
86        /// Length of the rejected input.
87        len: usize,
88        /// The cap that was exceeded.
89        max: usize,
90    },
91    /// The string is not valid bech32 with the expected checksum.
92    #[error("bech32 decoding failed: {0}")]
93    Decode(#[from] CheckedHrpstringError),
94    /// The HRP is not one of the NIP-19 prefixes.
95    #[error("unknown NIP-19 prefix `{0}`")]
96    UnknownHrp(String),
97    /// The HRP did not match the expected entity type.
98    #[error("expected NIP-19 prefix `{expected}`, got `{got}`")]
99    UnexpectedHrp {
100        /// Expected lowercase HRP.
101        expected: &'static str,
102        /// Actual lowercase HRP.
103        got: String,
104    },
105    /// A fixed-size payload had the wrong length.
106    #[error("expected {expected} bytes of payload, got {got}")]
107    InvalidPayloadLength {
108        /// Required number of bytes.
109        expected: usize,
110        /// Number of bytes seen.
111        got: usize,
112    },
113    /// A required TLV record was missing.
114    #[error("required TLV record (tag {tag}) is missing")]
115    MissingTlv {
116        /// Tag of the missing record.
117        tag: u8,
118    },
119    /// A `kind` TLV had the wrong length (must be 4 bytes).
120    #[error("kind TLV must be 4 bytes (got {got})")]
121    InvalidKindLength {
122        /// Number of bytes provided.
123        got: usize,
124    },
125    /// A `kind` TLV held a value that exceeds [`u16::MAX`]; nula's [`Kind`]
126    /// only stores 16-bit kinds even though NIP-19 reserves 32 bits.
127    #[error("kind value {raw} exceeds the supported 16-bit range")]
128    KindOutOfRange {
129        /// Raw 32-bit value decoded from the TLV.
130        raw: u32,
131    },
132    /// A relay TLV was not valid UTF-8.
133    #[error("relay TLV is not valid UTF-8: {0}")]
134    InvalidRelayUtf8(#[from] Utf8Error),
135    /// Forwarded TLV decoding error.
136    #[error(transparent)]
137    Tlv(#[from] TlvError),
138    /// Forwarded public-key validation error.
139    #[error(transparent)]
140    PublicKey(#[from] PublicKeyError),
141    /// Forwarded secret-key validation error.
142    #[error(transparent)]
143    SecretKey(#[from] SecretKeyError),
144    /// Forwarded event-id validation error.
145    #[error(transparent)]
146    EventId(#[from] EventIdError),
147    /// Forwarded relay-URL validation error.
148    #[error(transparent)]
149    RelayUrl(#[from] RelayUrlError),
150}
151
152/// Encode `Self` into its bech32 NIP-19 representation.
153///
154/// This trait is **sealed**: it can only be implemented for types defined
155/// in this crate. Downstream crates must not implement it because doing so
156/// would break the [NIP-19] HRP / TLV invariants we rely on for
157/// round-trip safety. To extend the encoding, contribute to `nula-core`.
158///
159/// [NIP-19]: https://github.com/nostr-protocol/nips/blob/master/19.md
160pub trait ToBech32: sealed::Sealed {
161    /// Produce the NIP-19 bech32 string.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`ToBech32Error`] if the underlying bech32 encoder rejects
166    /// the input or if a TLV value exceeds 255 bytes.
167    fn to_bech32(&self) -> Result<String, ToBech32Error>;
168}
169
170/// Decode `Self` from its bech32 NIP-19 representation.
171///
172/// This trait is **sealed** for the same reason as [`ToBech32`]: NIP-19
173/// HRPs and TLV layouts are defined by spec and any downstream
174/// implementation could violate the round-trip contract.
175pub trait FromBech32: sealed::Sealed + Sized {
176    /// Parse the given NIP-19 bech32 string.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`FromBech32Error`] if the input is not valid bech32, the HRP
181    /// is wrong, or any contained payload fails validation.
182    fn from_bech32(s: &str) -> Result<Self, FromBech32Error>;
183}
184
185/// Discriminated union over every NIP-19 entity.
186///
187/// Use [`Nip19Entity::from_bech32`] when you need to accept any NIP-19
188/// identifier from end-user input. The enum intentionally does **not**
189/// derive `Hash`: bundling secret keys with hashable variants would invite
190/// accidental side-channels through `HashSet`/`HashMap` use.
191#[derive(Debug, Clone, PartialEq, Eq)]
192#[non_exhaustive]
193pub enum Nip19Entity {
194    /// `npub`
195    PublicKey(PublicKey),
196    /// `nsec`
197    SecretKey(SecretKey),
198    /// `note`
199    EventId(EventId),
200    /// `nprofile`
201    Profile(Nip19Profile),
202    /// `nevent`
203    Event(Nip19Event),
204    /// `naddr`
205    Coordinate(Nip19Coordinate),
206}
207
208impl ToBech32 for Nip19Entity {
209    fn to_bech32(&self) -> Result<String, ToBech32Error> {
210        match self {
211            Self::PublicKey(pk) => pk.to_bech32(),
212            Self::SecretKey(sk) => sk.to_bech32(),
213            Self::EventId(id) => id.to_bech32(),
214            Self::Profile(p) => p.to_bech32(),
215            Self::Event(e) => e.to_bech32(),
216            Self::Coordinate(c) => c.to_bech32(),
217        }
218    }
219}
220
221impl FromBech32 for Nip19Entity {
222    #[cfg_attr(
223        feature = "tracing",
224        tracing::instrument(
225            level = "trace",
226            name = "nula.nip19.decode",
227            skip(s),
228            fields(nostr.nip = 19_u16, nostr.bech32.length = s.len()),
229        )
230    )]
231    fn from_bech32(s: &str) -> Result<Self, FromBech32Error> {
232        let (hrp_str, data) = decode_bech32(s)?;
233        #[cfg(feature = "tracing")]
234        tracing::trace!(nostr.bech32.hrp = %hrp_str, "dispatching NIP-19 variant");
235        match hrp_str.as_str() {
236            hrp::NPUB => Ok(Self::PublicKey(decode_pubkey(&data)?)),
237            hrp::NSEC => Ok(Self::SecretKey(decode_seckey(&data)?)),
238            hrp::NOTE => Ok(Self::EventId(decode_event_id(&data)?)),
239            hrp::NPROFILE => Ok(Self::Profile(decode_profile(&data)?)),
240            hrp::NEVENT => Ok(Self::Event(decode_nevent(&data)?)),
241            hrp::NADDR => Ok(Self::Coordinate(decode_naddr(&data)?)),
242            _ => Err(FromBech32Error::UnknownHrp(hrp_str)),
243        }
244    }
245}
246
247impl ToBech32 for PublicKey {
248    fn to_bech32(&self) -> Result<String, ToBech32Error> {
249        encode_raw(hrp::NPUB, &self.to_byte_array())
250    }
251}
252
253impl FromBech32 for PublicKey {
254    fn from_bech32(s: &str) -> Result<Self, FromBech32Error> {
255        let data = decode_with_hrp(s, hrp::NPUB)?;
256        decode_pubkey(&data)
257    }
258}
259
260impl ToBech32 for SecretKey {
261    fn to_bech32(&self) -> Result<String, ToBech32Error> {
262        encode_raw(hrp::NSEC, &self.to_byte_array())
263    }
264}
265
266impl FromBech32 for SecretKey {
267    fn from_bech32(s: &str) -> Result<Self, FromBech32Error> {
268        let data = decode_with_hrp(s, hrp::NSEC)?;
269        decode_seckey(&data)
270    }
271}
272
273impl ToBech32 for EventId {
274    fn to_bech32(&self) -> Result<String, ToBech32Error> {
275        encode_raw(hrp::NOTE, &self.to_byte_array())
276    }
277}
278
279impl FromBech32 for EventId {
280    fn from_bech32(s: &str) -> Result<Self, FromBech32Error> {
281        let data = decode_with_hrp(s, hrp::NOTE)?;
282        decode_event_id(&data)
283    }
284}
285
286impl ToBech32 for Nip19Profile {
287    fn to_bech32(&self) -> Result<String, ToBech32Error> {
288        let pk_bytes = self.public_key.to_byte_array();
289
290        let mut records: Vec<(u8, &[u8])> = Vec::with_capacity(1 + self.relays.len());
291        records.push((tlv::SPECIAL, pk_bytes.as_slice()));
292        for relay in &self.relays {
293            records.push((tlv::RELAY, relay.as_str().as_bytes()));
294        }
295
296        let payload = tlv::encode(records)?;
297        encode_raw(hrp::NPROFILE, &payload)
298    }
299}
300
301impl FromBech32 for Nip19Profile {
302    fn from_bech32(s: &str) -> Result<Self, FromBech32Error> {
303        let data = decode_with_hrp(s, hrp::NPROFILE)?;
304        decode_profile(&data)
305    }
306}
307
308impl ToBech32 for Nip19Event {
309    fn to_bech32(&self) -> Result<String, ToBech32Error> {
310        let id_bytes = self.event_id.to_byte_array();
311        let author_bytes = self.author.map(PublicKey::to_byte_array);
312        let kind_bytes = self.kind.map(|k| u32::from(k.as_u16()).to_be_bytes());
313
314        let mut records: Vec<(u8, &[u8])> = Vec::with_capacity(1 + self.relays.len() + 2);
315        records.push((tlv::SPECIAL, id_bytes.as_slice()));
316        for relay in &self.relays {
317            records.push((tlv::RELAY, relay.as_str().as_bytes()));
318        }
319        if let Some(bytes) = author_bytes.as_ref() {
320            records.push((tlv::AUTHOR, bytes.as_slice()));
321        }
322        if let Some(bytes) = kind_bytes.as_ref() {
323            records.push((tlv::KIND, bytes.as_slice()));
324        }
325
326        let payload = tlv::encode(records)?;
327        encode_raw(hrp::NEVENT, &payload)
328    }
329}
330
331impl FromBech32 for Nip19Event {
332    fn from_bech32(s: &str) -> Result<Self, FromBech32Error> {
333        let data = decode_with_hrp(s, hrp::NEVENT)?;
334        decode_nevent(&data)
335    }
336}
337
338impl ToBech32 for Nip19Coordinate {
339    fn to_bech32(&self) -> Result<String, ToBech32Error> {
340        let identifier_bytes = self.coordinate.identifier.as_bytes();
341        let author_bytes = self.coordinate.author.to_byte_array();
342        let kind_bytes = u32::from(self.coordinate.kind.as_u16()).to_be_bytes();
343
344        let mut records: Vec<(u8, &[u8])> = Vec::with_capacity(3 + self.relays.len());
345        records.push((tlv::SPECIAL, identifier_bytes));
346        for relay in &self.relays {
347            records.push((tlv::RELAY, relay.as_str().as_bytes()));
348        }
349        records.push((tlv::AUTHOR, author_bytes.as_slice()));
350        records.push((tlv::KIND, kind_bytes.as_slice()));
351
352        let payload = tlv::encode(records)?;
353        encode_raw(hrp::NADDR, &payload)
354    }
355}
356
357impl FromBech32 for Nip19Coordinate {
358    fn from_bech32(s: &str) -> Result<Self, FromBech32Error> {
359        let data = decode_with_hrp(s, hrp::NADDR)?;
360        decode_naddr(&data)
361    }
362}
363
364/// Private module that prevents downstream crates from implementing
365/// [`ToBech32`] / [`FromBech32`] for their own types.
366mod sealed {
367    use super::{
368        EventId, Nip19Coordinate, Nip19Entity, Nip19Event, Nip19Profile, PublicKey, SecretKey,
369    };
370
371    /// Marker trait that limits the set of `ToBech32` / `FromBech32`
372    /// implementors to the types defined in this crate.
373    pub trait Sealed {}
374
375    impl Sealed for PublicKey {}
376    impl Sealed for SecretKey {}
377    impl Sealed for EventId {}
378    impl Sealed for Nip19Profile {}
379    impl Sealed for Nip19Event {}
380    impl Sealed for Nip19Coordinate {}
381    impl Sealed for Nip19Entity {}
382}
383
384fn encode_raw(hrp_value: &'static str, data: &[u8]) -> Result<String, ToBech32Error> {
385    let hrp = hrp::hrp_unchecked(hrp_value);
386    let encoded = bech32::encode::<Bech32>(hrp, data)?;
387    Ok(encoded)
388}
389
390fn decode_bech32(s: &str) -> Result<(String, Vec<u8>), FromBech32Error> {
391    // Enforce the NIP-19 §Notes cap before touching the bech32 state machine
392    // so adversarial input can never allocate more than ~5 KiB even when the
393    // checksum check would otherwise sweep the full string.
394    if s.len() > MAX_NIP19_LENGTH {
395        return Err(FromBech32Error::TooLong {
396            len: s.len(),
397            max: MAX_NIP19_LENGTH,
398        });
399    }
400    let parsed = CheckedHrpstring::new::<Bech32>(s)?;
401    let hrp_str = parsed.hrp().to_lowercase();
402    let data: Vec<u8> = parsed.byte_iter().collect();
403    Ok((hrp_str, data))
404}
405
406fn decode_with_hrp(s: &str, expected: &'static str) -> Result<Vec<u8>, FromBech32Error> {
407    let (hrp_str, data) = decode_bech32(s)?;
408    if hrp_str != expected {
409        return Err(FromBech32Error::UnexpectedHrp {
410            expected,
411            got: hrp_str,
412        });
413    }
414    Ok(data)
415}
416
417fn decode_pubkey(data: &[u8]) -> Result<PublicKey, FromBech32Error> {
418    expect_len(data, 32)?;
419    Ok(PublicKey::from_slice(data)?)
420}
421
422fn decode_seckey(data: &[u8]) -> Result<SecretKey, FromBech32Error> {
423    expect_len(data, 32)?;
424    Ok(SecretKey::from_slice(data)?)
425}
426
427fn decode_event_id(data: &[u8]) -> Result<EventId, FromBech32Error> {
428    expect_len(data, 32)?;
429    Ok(EventId::from_slice(data)?)
430}
431
432const fn expect_len(data: &[u8], expected: usize) -> Result<(), FromBech32Error> {
433    if data.len() != expected {
434        return Err(FromBech32Error::InvalidPayloadLength {
435            expected,
436            got: data.len(),
437        });
438    }
439    Ok(())
440}
441
442fn decode_profile(data: &[u8]) -> Result<Nip19Profile, FromBech32Error> {
443    let mut public_key: Option<PublicKey> = None;
444    let mut relays: Vec<RelayUrl> = Vec::new();
445
446    for record in tlv::iter(data) {
447        let record = record?;
448        match record.tag {
449            tlv::SPECIAL => {
450                public_key = Some(decode_pubkey(record.value)?);
451            }
452            tlv::RELAY => {
453                relays.push(parse_relay(record.value)?);
454            }
455            _ => {} // Forward-compatible: ignore unknown tags.
456        }
457    }
458
459    let public_key = public_key.ok_or(FromBech32Error::MissingTlv { tag: tlv::SPECIAL })?;
460    Ok(Nip19Profile { public_key, relays })
461}
462
463fn decode_nevent(data: &[u8]) -> Result<Nip19Event, FromBech32Error> {
464    let mut event_id: Option<EventId> = None;
465    let mut author: Option<PublicKey> = None;
466    let mut kind: Option<Kind> = None;
467    let mut relays: Vec<RelayUrl> = Vec::new();
468
469    for record in tlv::iter(data) {
470        let record = record?;
471        match record.tag {
472            tlv::SPECIAL => event_id = Some(decode_event_id(record.value)?),
473            tlv::RELAY => relays.push(parse_relay(record.value)?),
474            tlv::AUTHOR => author = Some(decode_pubkey(record.value)?),
475            tlv::KIND => kind = Some(decode_kind(record.value)?),
476            _ => {}
477        }
478    }
479
480    let event_id = event_id.ok_or(FromBech32Error::MissingTlv { tag: tlv::SPECIAL })?;
481    Ok(Nip19Event {
482        event_id,
483        author,
484        kind,
485        relays,
486    })
487}
488
489fn decode_naddr(data: &[u8]) -> Result<Nip19Coordinate, FromBech32Error> {
490    let mut identifier: Option<String> = None;
491    let mut author: Option<PublicKey> = None;
492    let mut kind: Option<Kind> = None;
493    let mut relays: Vec<RelayUrl> = Vec::new();
494
495    for record in tlv::iter(data) {
496        let record = record?;
497        match record.tag {
498            tlv::SPECIAL => identifier = Some(str::from_utf8(record.value)?.to_owned()),
499            tlv::RELAY => relays.push(parse_relay(record.value)?),
500            tlv::AUTHOR => author = Some(decode_pubkey(record.value)?),
501            tlv::KIND => kind = Some(decode_kind(record.value)?),
502            _ => {}
503        }
504    }
505
506    let identifier = identifier.ok_or(FromBech32Error::MissingTlv { tag: tlv::SPECIAL })?;
507    let author = author.ok_or(FromBech32Error::MissingTlv { tag: tlv::AUTHOR })?;
508    let kind = kind.ok_or(FromBech32Error::MissingTlv { tag: tlv::KIND })?;
509    Ok(Nip19Coordinate {
510        coordinate: crate::event::Coordinate::new(kind, author, identifier),
511        relays,
512    })
513}
514
515fn decode_kind(value: &[u8]) -> Result<Kind, FromBech32Error> {
516    let bytes: [u8; 4] = value
517        .try_into()
518        .map_err(|_| FromBech32Error::InvalidKindLength { got: value.len() })?;
519    let raw = u32::from_be_bytes(bytes);
520    let narrowed = u16::try_from(raw).map_err(|_| FromBech32Error::KindOutOfRange { raw })?;
521    Ok(Kind::from(narrowed))
522}
523
524fn parse_relay(value: &[u8]) -> Result<RelayUrl, FromBech32Error> {
525    let s = str::from_utf8(value)?;
526    Ok(RelayUrl::parse(s)?)
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::Keys;
533
534    fn fixture_keys() -> Keys {
535        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
536    }
537
538    fn relay(url: &str) -> RelayUrl {
539        RelayUrl::parse(url).unwrap()
540    }
541
542    #[test]
543    fn npub_round_trip() {
544        let pk = *fixture_keys().public_key();
545        let encoded = pk.to_bech32().unwrap();
546        assert!(encoded.starts_with("npub1"));
547        let parsed = PublicKey::from_bech32(&encoded).unwrap();
548        assert_eq!(parsed, pk);
549    }
550
551    #[test]
552    fn nsec_round_trip() {
553        let sk = fixture_keys().secret_key().clone();
554        let encoded = sk.to_bech32().unwrap();
555        assert!(encoded.starts_with("nsec1"));
556        let parsed = SecretKey::from_bech32(&encoded).unwrap();
557        assert_eq!(parsed, sk);
558    }
559
560    #[test]
561    fn note_round_trip() {
562        let id = EventId::from_byte_array([0xab; 32]);
563        let encoded = id.to_bech32().unwrap();
564        assert!(encoded.starts_with("note1"));
565        let parsed = EventId::from_bech32(&encoded).unwrap();
566        assert_eq!(parsed, id);
567    }
568
569    #[test]
570    fn nprofile_round_trip_with_relays() {
571        let pk = *fixture_keys().public_key();
572        let profile = Nip19Profile::new(pk, [relay("wss://relay.one"), relay("wss://relay.two")]);
573        let encoded = profile.to_bech32().unwrap();
574        assert!(encoded.starts_with("nprofile1"));
575        let parsed = Nip19Profile::from_bech32(&encoded).unwrap();
576        assert_eq!(parsed, profile);
577    }
578
579    #[test]
580    fn nprofile_round_trip_without_relays() {
581        let pk = *fixture_keys().public_key();
582        let profile = Nip19Profile::new(pk, []);
583        let encoded = profile.to_bech32().unwrap();
584        let parsed = Nip19Profile::from_bech32(&encoded).unwrap();
585        assert_eq!(parsed, profile);
586    }
587
588    #[test]
589    fn nevent_round_trip_full() {
590        let pk = *fixture_keys().public_key();
591        let event = Nip19Event::new(EventId::from_byte_array([0xab; 32]))
592            .with_author(pk)
593            .with_kind(Kind::TEXT_NOTE)
594            .with_relays([relay("wss://relay.example")]);
595        let encoded = event.to_bech32().unwrap();
596        assert!(encoded.starts_with("nevent1"));
597        let parsed = Nip19Event::from_bech32(&encoded).unwrap();
598        assert_eq!(parsed, event);
599    }
600
601    #[test]
602    fn nevent_round_trip_minimal() {
603        let event = Nip19Event::new(EventId::from_byte_array([0x01; 32]));
604        let encoded = event.to_bech32().unwrap();
605        let parsed = Nip19Event::from_bech32(&encoded).unwrap();
606        assert_eq!(parsed, event);
607    }
608
609    #[test]
610    fn naddr_round_trip() {
611        let pk = *fixture_keys().public_key();
612        let coord = Nip19Coordinate::new(
613            "long-form-1",
614            pk,
615            Kind::from(30_023_u16),
616            [relay("wss://relay.example")],
617        );
618        let encoded = coord.to_bech32().unwrap();
619        assert!(encoded.starts_with("naddr1"));
620        let parsed = Nip19Coordinate::from_bech32(&encoded).unwrap();
621        assert_eq!(parsed, coord);
622    }
623
624    #[test]
625    fn entity_dispatch_npub() {
626        let pk = *fixture_keys().public_key();
627        let s = pk.to_bech32().unwrap();
628        let parsed = Nip19Entity::from_bech32(&s).unwrap();
629        assert_eq!(parsed, Nip19Entity::PublicKey(pk));
630    }
631
632    #[test]
633    fn entity_dispatch_naddr() {
634        let pk = *fixture_keys().public_key();
635        let coord = Nip19Coordinate::new("alpha", pk, Kind::from(30_001_u16), []);
636        let s = coord.to_bech32().unwrap();
637        let parsed = Nip19Entity::from_bech32(&s).unwrap();
638        assert_eq!(parsed, Nip19Entity::Coordinate(coord));
639    }
640
641    #[test]
642    fn entity_round_trip_via_to_bech32() {
643        let pk = *fixture_keys().public_key();
644        let entity = Nip19Entity::PublicKey(pk);
645        let s = entity.to_bech32().unwrap();
646        assert_eq!(Nip19Entity::from_bech32(&s).unwrap(), entity);
647    }
648
649    #[test]
650    fn unexpected_hrp_is_rejected() {
651        let pk = *fixture_keys().public_key();
652        let s = pk.to_bech32().unwrap();
653        let err = SecretKey::from_bech32(&s).unwrap_err();
654        assert!(matches!(
655            err,
656            FromBech32Error::UnexpectedHrp {
657                expected: hrp::NSEC,
658                ..
659            }
660        ));
661    }
662
663    #[test]
664    fn unknown_hrp_is_rejected() {
665        // Generate any bech32 string with an unrelated HRP.
666        let hrp = bech32::Hrp::parse("xyz").unwrap();
667        let bogus = bech32::encode::<Bech32>(hrp, &[0u8; 32]).unwrap();
668        let err = Nip19Entity::from_bech32(&bogus).unwrap_err();
669        assert!(matches!(err, FromBech32Error::UnknownHrp(s) if s == "xyz"));
670    }
671
672    #[test]
673    fn malformed_string_is_rejected() {
674        let err = Nip19Entity::from_bech32("definitely not bech32").unwrap_err();
675        assert!(matches!(err, FromBech32Error::Decode(_)));
676    }
677
678    #[test]
679    fn missing_required_tlv_is_rejected() {
680        // Build an empty TLV payload and wrap it in a valid `nprofile`.
681        let payload = tlv::encode([(tlv::RELAY, b"wss://relay.example".as_slice())]).unwrap();
682        let encoded = encode_raw(hrp::NPROFILE, &payload).unwrap();
683        let err = Nip19Profile::from_bech32(&encoded).unwrap_err();
684        assert!(matches!(
685            err,
686            FromBech32Error::MissingTlv { tag: tlv::SPECIAL }
687        ));
688    }
689
690    #[test]
691    fn naddr_kind_above_u16_is_rejected_not_truncated() {
692        // NIP-19 encodes the `kind` TLV as a 32-bit big-endian integer,
693        // but nula's `Kind` is a 16-bit type. nula refuses any kind that
694        // does not fit in `u16` (`KindOutOfRange`) instead of silently
695        // mangling it.
696        //
697        // Interop note: `rust-nostr` 0.45 decodes the same TLV with
698        // `u32::from_be_bytes(..) as u16` (`nip19.rs`), which *truncates*
699        // out-of-range kinds (e.g. 70000 -> 4464) and accepts the address
700        // with a corrupted kind. nula's reject-don't-truncate stance is
701        // the safer one; this test pins the divergence.
702        let pk = *fixture_keys().public_key();
703        let oversized_kind: u32 = 70_000; // > u16::MAX (65_535)
704        let payload = tlv::encode([
705            (tlv::SPECIAL, b"alpha".as_slice()),
706            (tlv::AUTHOR, pk.to_byte_array().as_slice()),
707            (tlv::KIND, oversized_kind.to_be_bytes().as_slice()),
708        ])
709        .unwrap();
710        let encoded = encode_raw(hrp::NADDR, &payload).unwrap();
711        let err = Nip19Entity::from_bech32(&encoded).unwrap_err();
712        assert!(matches!(
713            err,
714            FromBech32Error::KindOutOfRange { raw } if raw == oversized_kind
715        ));
716    }
717
718    /// Vectors copied verbatim from the [NIP-19 specification].
719    ///
720    /// [NIP-19 specification]: https://github.com/nostr-protocol/nips/blob/master/19.md
721    mod nip19_vectors {
722        use super::*;
723
724        #[test]
725        fn npub_matches_spec() {
726            let pubkey = PublicKey::parse(
727                "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e",
728            )
729            .unwrap();
730            let expected = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
731            assert_eq!(pubkey.to_bech32().unwrap(), expected);
732            assert_eq!(PublicKey::from_bech32(expected).unwrap(), pubkey);
733        }
734
735        #[test]
736        fn nsec_matches_spec() {
737            let sk = SecretKey::parse(
738                "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa",
739            )
740            .unwrap();
741            let expected = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5";
742            assert_eq!(sk.to_bech32().unwrap(), expected);
743            assert_eq!(SecretKey::from_bech32(expected).unwrap(), sk);
744        }
745
746        #[test]
747        fn nprofile_round_trip_with_canonical_relays() {
748            // The historical NIP-19 example uses URLs without a trailing slash
749            // (`wss://r.x.com`); modern URL parsers (RFC 3986 + WHATWG) always
750            // normalise them to `wss://r.x.com/`. We mirror rust-nostr by
751            // testing against the canonical form, which is what every
752            // production stack actually emits today.
753            let pk = PublicKey::parse(
754                "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
755            )
756            .unwrap();
757            let profile = Nip19Profile::new(
758                pk,
759                [
760                    RelayUrl::parse("wss://r.x.com/").unwrap(),
761                    RelayUrl::parse("wss://djbas.sadkb.com/").unwrap(),
762                ],
763            );
764            let expected = "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gppemhxue69uhhytnc9e3k7mf0qyt8wumn8ghj7er2vfshxtnnv9jxkc3wvdhk6tclr7lsh";
765            assert_eq!(profile.to_bech32().unwrap(), expected);
766            assert_eq!(Nip19Profile::from_bech32(expected).unwrap(), profile);
767        }
768    }
769
770    /// Cross-implementation fixtures sourced from `3rdparty/nostr-tools` and
771    /// real-world clients. These pin our decoder against bytes produced by
772    /// other implementations, especially the ones that emit a different
773    /// TLV ordering than nula-core does. NIP-19 leaves TLV order
774    /// unspecified, so the only invariant the decoder may rely on is the
775    /// per-record `(tag, length, value)` shape — never the position.
776    mod cross_impl_fixtures {
777        use super::*;
778
779        /// `naddr` produced by [habla.news](https://habla.news), pinned in
780        /// `3rdparty/nostr-tools/nip19.test.ts`. The relays vector is
781        /// empty; this guards the no-relay path.
782        #[test]
783        fn habla_news_naddr_decodes() {
784            let raw = "naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5";
785            let decoded = Nip19Coordinate::from_bech32(raw).unwrap();
786            assert_eq!(
787                decoded.coordinate.author.to_hex(),
788                "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194"
789            );
790            assert_eq!(decoded.coordinate.kind.as_u16(), 30_023);
791            assert_eq!(decoded.coordinate.identifier, "references");
792            assert!(decoded.relays.is_empty());
793        }
794
795        /// `naddr` produced by [go-nostr](https://github.com/nbd-wtf/go-nostr)
796        /// with TLV records in a *different* order than nula emits. NIP-19
797        /// allows any ordering, so the decoder must not assume position.
798        /// Pinned in `3rdparty/nostr-tools/nip19.test.ts`.
799        #[test]
800        fn go_nostr_naddr_with_alternate_tlv_ordering_decodes() {
801            let raw = "naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx";
802            let decoded = Nip19Coordinate::from_bech32(raw).unwrap();
803            assert_eq!(
804                decoded.coordinate.author.to_hex(),
805                "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
806            );
807            assert_eq!(decoded.coordinate.kind.as_u16(), 30_023);
808            assert_eq!(decoded.coordinate.identifier, "banana");
809            // Both relays from the fixture must round-trip; check membership
810            // because the TLV ordering does not promise relay-list order.
811            let relay_strs: Vec<&str> = decoded.relays.iter().map(RelayUrl::as_str).collect();
812            assert!(
813                relay_strs
814                    .iter()
815                    .any(|r| r == &"wss://relay.nostr.example.mydomain.example.com/"),
816                "missing primary relay; got {relay_strs:?}",
817            );
818            assert!(
819                relay_strs.iter().any(|r| r == &"wss://nostr.banana.com/"),
820                "missing secondary relay; got {relay_strs:?}",
821            );
822        }
823
824        /// `nprofile` in the trivial form pinned in nostr-tools'
825        /// `NostrTypeGuard.isNProfile` test. Confirms that npub-only
826        /// profiles (no relay TLV) round-trip through our decoder
827        /// regardless of where the producer placed the `special` record.
828        #[test]
829        fn nostr_tools_nprofile_no_relays_decodes() {
830            let raw = "nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg";
831            let decoded = Nip19Profile::from_bech32(raw).unwrap();
832            assert!(decoded.relays.is_empty());
833            // The decoded pubkey must be a valid x-only point; we do not
834            // pin its bytes because the test in nostr-tools also leaves
835            // them implicit. Round-tripping through to_bech32 would then
836            // emit our canonical TLV order, which may differ from this
837            // wire form.
838            assert_eq!(decoded.public_key.to_byte_array().len(), 32);
839        }
840
841        /// `nevent` from nostr-tools' `NostrTypeGuard.isNEvent` test. The
842        /// fixture relies on TLV records `(SPECIAL, RELAY, RELAY)`.
843        #[test]
844        fn nostr_tools_nevent_with_relays_decodes() {
845            let raw = "nevent1qqst8cujky046negxgwwm5ynqwn53t8aqjr6afd8g59nfqwxpdhylpcpzamhxue69uhhyetvv9ujuetcv9khqmr99e3k7mg8arnc9";
846            let decoded = Nip19Event::from_bech32(raw).unwrap();
847            // The id is 32 bytes; we just confirm shape, mirroring
848            // nostr-tools' boolean-returning type guard.
849            assert_eq!(decoded.event_id.to_byte_array().len(), 32);
850            assert!(!decoded.relays.is_empty(), "fixture carries relay hints");
851        }
852    }
853
854    #[test]
855    fn rejects_input_above_max_length() {
856        // Construct a string that is *syntactically* a bech32 candidate
857        // (lowercase ascii + a `1` separator) but longer than the cap.
858        // The length check must fire before any expensive bech32 work.
859        let oversized: String = std::iter::repeat_n('q', MAX_NIP19_LENGTH + 1).collect();
860        let err = Nip19Entity::from_bech32(&oversized).unwrap_err();
861        assert!(matches!(
862            err,
863            FromBech32Error::TooLong {
864                len,
865                max: MAX_NIP19_LENGTH,
866            } if len == MAX_NIP19_LENGTH + 1
867        ));
868    }
869
870    #[test]
871    fn accepts_input_at_max_length_boundary() {
872        // A npub is 63 characters; padding it up to exactly 5000 with extra
873        // garbage data is rejected by the bech32 decoder, but the length
874        // check must *not* fire — that decision belongs to the checksum.
875        let pk = *fixture_keys().public_key();
876        let mut s = pk.to_bech32().unwrap();
877        while s.len() < MAX_NIP19_LENGTH {
878            s.push('q');
879        }
880        assert_eq!(s.len(), MAX_NIP19_LENGTH);
881        let err = Nip19Entity::from_bech32(&s).unwrap_err();
882        // The cap did not fire; the bech32 checksum did instead.
883        assert!(!matches!(err, FromBech32Error::TooLong { .. }));
884    }
885
886    #[test]
887    fn unknown_tlv_tag_is_ignored_for_forward_compat() {
888        let pk = *fixture_keys().public_key();
889        let pk_bytes = pk.to_byte_array();
890        let future_value: &[u8] = b"future";
891        let payload =
892            tlv::encode([(tlv::SPECIAL, pk_bytes.as_slice()), (250_u8, future_value)]).unwrap();
893        let encoded = encode_raw(hrp::NPROFILE, &payload).unwrap();
894        let parsed = Nip19Profile::from_bech32(&encoded).unwrap();
895        assert_eq!(parsed.public_key, pk);
896    }
897}