1use thiserror::Error;
54
55use super::nip19::{
56 FromBech32, FromBech32Error, Nip19Coordinate, Nip19Entity, Nip19Event, Nip19Profile, ToBech32,
57 ToBech32Error,
58};
59use crate::event::EventId;
60use crate::key::PublicKey;
61
62pub const SCHEME: &str = "nostr";
64
65pub const SCHEME_PREFIX: &str = "nostr:";
68
69#[derive(Debug, Error)]
71#[non_exhaustive]
72pub enum Nip21Error {
73 #[error("invalid `nostr:` URI: expected `nostr:<bech32>` with a non-empty body")]
75 InvalidUri,
76 #[error(
79 "NIP-21 does not permit secret keys in URIs; pass a public key, profile, or event instead"
80 )]
81 SecretKeyRefused,
82 #[error(transparent)]
84 Decode(#[from] FromBech32Error),
85 #[error(transparent)]
87 Encode(#[from] ToBech32Error),
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Hash)]
98#[non_exhaustive]
99pub enum Nip21 {
100 Pubkey(PublicKey),
102 EventId(EventId),
104 Profile(Nip19Profile),
106 Event(Nip19Event),
108 Coordinate(Nip19Coordinate),
110}
111
112impl Nip21 {
113 pub fn parse(uri: &str) -> Result<Self, Nip21Error> {
123 let body = strip_scheme(uri).ok_or(Nip21Error::InvalidUri)?;
124 let entity = Nip19Entity::from_bech32(body)?;
125 Self::try_from(entity)
126 }
127
128 pub fn to_bech32_body(&self) -> Result<String, Nip21Error> {
140 let body = match self {
141 Self::Pubkey(pk) => pk.to_bech32()?,
142 Self::EventId(id) => id.to_bech32()?,
143 Self::Profile(p) => p.to_bech32()?,
144 Self::Event(e) => e.to_bech32()?,
145 Self::Coordinate(c) => c.to_bech32()?,
146 };
147 Ok(body)
148 }
149
150 pub fn to_nostr_uri(&self) -> Result<String, Nip21Error> {
157 let body = self.to_bech32_body()?;
158 Ok(format!("{SCHEME_PREFIX}{body}"))
159 }
160
161 #[must_use]
166 pub const fn event_id(&self) -> Option<EventId> {
167 match self {
168 Self::EventId(id) => Some(*id),
169 Self::Event(e) => Some(e.event_id),
170 Self::Pubkey(_) | Self::Profile(_) | Self::Coordinate(_) => None,
171 }
172 }
173
174 #[must_use]
180 pub const fn pubkey(&self) -> Option<PublicKey> {
181 match self {
182 Self::Pubkey(pk) => Some(*pk),
183 Self::Profile(p) => Some(p.public_key),
184 Self::Coordinate(c) => Some(*c.author()),
185 Self::EventId(_) | Self::Event(_) => None,
186 }
187 }
188}
189
190impl From<Nip21> for Nip19Entity {
191 fn from(value: Nip21) -> Self {
192 match value {
193 Nip21::Pubkey(pk) => Self::PublicKey(pk),
194 Nip21::EventId(id) => Self::EventId(id),
195 Nip21::Profile(p) => Self::Profile(p),
196 Nip21::Event(e) => Self::Event(e),
197 Nip21::Coordinate(c) => Self::Coordinate(c),
198 }
199 }
200}
201
202impl TryFrom<Nip19Entity> for Nip21 {
203 type Error = Nip21Error;
204
205 fn try_from(value: Nip19Entity) -> Result<Self, Self::Error> {
206 match value {
207 Nip19Entity::SecretKey(_) => Err(Nip21Error::SecretKeyRefused),
208 Nip19Entity::PublicKey(pk) => Ok(Self::Pubkey(pk)),
209 Nip19Entity::EventId(id) => Ok(Self::EventId(id)),
210 Nip19Entity::Profile(p) => Ok(Self::Profile(p)),
211 Nip19Entity::Event(e) => Ok(Self::Event(e)),
212 Nip19Entity::Coordinate(c) => Ok(Self::Coordinate(c)),
213 }
214 }
215}
216
217pub trait ToNostrUri: sealed::ToSealed {
226 fn to_nostr_uri(&self) -> Result<String, Nip21Error>;
232}
233
234pub trait FromNostrUri: sealed::FromSealed + Sized {
238 fn from_nostr_uri(uri: &str) -> Result<Self, Nip21Error>;
246}
247
248mod sealed {
249 use super::{EventId, Nip19Coordinate, Nip19Event, Nip19Profile, Nip21, PublicKey};
250
251 pub trait ToSealed {}
254 pub trait FromSealed {}
256
257 impl ToSealed for PublicKey {}
258 impl ToSealed for EventId {}
259 impl ToSealed for Nip19Profile {}
260 impl ToSealed for Nip19Event {}
261 impl ToSealed for Nip19Coordinate {}
262 impl ToSealed for Nip21 {}
263 impl FromSealed for PublicKey {}
264 impl FromSealed for EventId {}
265 impl FromSealed for Nip19Profile {}
266 impl FromSealed for Nip19Event {}
267 impl FromSealed for Nip19Coordinate {}
268 impl FromSealed for Nip21 {}
269}
270
271fn strip_scheme(uri: &str) -> Option<&str> {
272 let body = uri.strip_prefix(SCHEME_PREFIX)?;
273 if body.is_empty() { None } else { Some(body) }
274}
275
276macro_rules! impl_to_nostr_uri_via_bech32 {
277 ($($ty:ty),+ $(,)?) => {
278 $(
279 impl ToNostrUri for $ty {
280 fn to_nostr_uri(&self) -> Result<String, Nip21Error> {
281 let body = ToBech32::to_bech32(self)?;
282 Ok(format!("{SCHEME_PREFIX}{body}"))
283 }
284 }
285 )+
286 };
287}
288
289macro_rules! impl_from_nostr_uri_via_bech32 {
290 ($($ty:ty),+ $(,)?) => {
291 $(
292 impl FromNostrUri for $ty {
293 fn from_nostr_uri(uri: &str) -> Result<Self, Nip21Error> {
294 let body = strip_scheme(uri).ok_or(Nip21Error::InvalidUri)?;
295 Self::from_bech32(body).map_err(Nip21Error::Decode)
296 }
297 }
298 )+
299 };
300}
301
302impl_to_nostr_uri_via_bech32!(
303 PublicKey,
304 EventId,
305 Nip19Profile,
306 Nip19Event,
307 Nip19Coordinate
308);
309impl_from_nostr_uri_via_bech32!(
310 PublicKey,
311 EventId,
312 Nip19Profile,
313 Nip19Event,
314 Nip19Coordinate
315);
316
317impl ToNostrUri for Nip21 {
318 fn to_nostr_uri(&self) -> Result<String, Nip21Error> {
319 Self::to_nostr_uri(self)
320 }
321}
322
323impl FromNostrUri for Nip21 {
324 fn from_nostr_uri(uri: &str) -> Result<Self, Nip21Error> {
325 Self::parse(uri)
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::event::{EventId, Kind};
333 use crate::types::RelayUrl;
334
335 const FIXTURE_PUBKEY_HEX: &str =
336 "aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4";
337 const FIXTURE_NPUB_URI: &str =
338 "nostr:npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy";
339
340 fn fixture_pubkey() -> PublicKey {
341 PublicKey::parse(FIXTURE_PUBKEY_HEX).expect("fixture hex parses")
342 }
343
344 #[test]
345 fn pubkey_round_trip_matches_upstream_fixture() {
346 let pk = fixture_pubkey();
347 assert_eq!(pk.to_nostr_uri().unwrap(), FIXTURE_NPUB_URI);
348 assert_eq!(PublicKey::from_nostr_uri(FIXTURE_NPUB_URI).unwrap(), pk);
349
350 let parsed = Nip21::parse(FIXTURE_NPUB_URI).unwrap();
352 assert_eq!(parsed, Nip21::Pubkey(pk));
353 assert_eq!(parsed.pubkey(), Some(pk));
354 assert_eq!(parsed.event_id(), None);
355 assert_eq!(parsed.to_nostr_uri().unwrap(), FIXTURE_NPUB_URI);
356 }
357
358 #[test]
359 fn profile_round_trip() {
360 let pk = fixture_pubkey();
361 let profile = Nip19Profile::new(
362 pk,
363 [RelayUrl::parse("wss://relay.damus.io/").expect("fixture relay parses")],
364 );
365
366 let uri = profile.to_nostr_uri().unwrap();
367 assert!(uri.starts_with("nostr:nprofile"));
368 let round_trip = Nip19Profile::from_nostr_uri(&uri).unwrap();
369 assert_eq!(round_trip, profile);
370 assert_eq!(Nip21::parse(&uri).unwrap(), Nip21::Profile(profile));
371 }
372
373 #[test]
374 fn event_round_trip_preserves_discriminator_accessors() {
375 let id = EventId::parse("b2f61aa5ce66cef9f9e3dcbfa9a17b16b6b9d43f7e0a8e2b7c5f1e6f80a7f123")
376 .expect("fixture event id parses");
377 let nevent = Nip19Event::new(id)
378 .with_author(fixture_pubkey())
379 .with_kind(Kind::TEXT_NOTE)
380 .with_relays([RelayUrl::parse("wss://relay.damus.io/").unwrap()]);
381
382 let uri = nevent.to_nostr_uri().unwrap();
383 assert!(uri.starts_with("nostr:nevent"));
384
385 let parsed = Nip21::parse(&uri).unwrap();
386 assert_eq!(parsed.event_id(), Some(id));
387 assert_eq!(parsed.pubkey(), None);
388 assert!(matches!(parsed, Nip21::Event(_)));
389 }
390
391 #[test]
392 fn secret_key_is_refused_at_parse() {
393 let nsec_uri = "nostr:nsec1j4c6269y9w0q2er2xjw8sv2ehyrtfxq3jwgdlxj6qfn8z4gjsq5qfvfk99";
395 let err = Nip21::parse(nsec_uri).expect_err("nsec URIs are forbidden");
396 assert!(matches!(err, Nip21Error::SecretKeyRefused));
397 }
398
399 #[test]
400 fn missing_scheme_is_rejected() {
401 for bad in [
402 "npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy",
403 "nostr:",
404 ] {
405 let err = Nip21::parse(bad).expect_err("Nip21::parse accepts only `nostr:<bech32>`");
406 assert!(
407 matches!(err, Nip21Error::InvalidUri),
408 "unexpected error for {bad:?}: {err:?}"
409 );
410 }
411
412 let trait_err =
413 PublicKey::from_nostr_uri("bolt11:lnbc1…").expect_err("foreign scheme is not NIP-21");
414 assert!(matches!(trait_err, Nip21Error::InvalidUri));
415 }
416
417 #[test]
418 fn nip19_entity_bidirectional_conversion() {
419 let pk = fixture_pubkey();
420 let as_entity: Nip19Entity = Nip21::Pubkey(pk).into();
421 assert_eq!(as_entity, Nip19Entity::PublicKey(pk));
422
423 let back = Nip21::try_from(as_entity).unwrap();
424 assert_eq!(back, Nip21::Pubkey(pk));
425
426 let sk = crate::SecretKey::parse(
428 "0000000000000000000000000000000000000000000000000000000000000003",
429 )
430 .unwrap();
431 let err = Nip21::try_from(Nip19Entity::SecretKey(sk))
432 .expect_err("secret keys must not become NIP-21 values");
433 assert!(matches!(err, Nip21Error::SecretKeyRefused));
434 }
435}