use thiserror::Error;
use super::nip19::{
FromBech32, FromBech32Error, Nip19Coordinate, Nip19Entity, Nip19Event, Nip19Profile, ToBech32,
ToBech32Error,
};
use crate::event::EventId;
use crate::key::PublicKey;
pub const SCHEME: &str = "nostr";
pub const SCHEME_PREFIX: &str = "nostr:";
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip21Error {
#[error("invalid `nostr:` URI: expected `nostr:<bech32>` with a non-empty body")]
InvalidUri,
#[error(
"NIP-21 does not permit secret keys in URIs; pass a public key, profile, or event instead"
)]
SecretKeyRefused,
#[error(transparent)]
Decode(#[from] FromBech32Error),
#[error(transparent)]
Encode(#[from] ToBech32Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Nip21 {
Pubkey(PublicKey),
EventId(EventId),
Profile(Nip19Profile),
Event(Nip19Event),
Coordinate(Nip19Coordinate),
}
impl Nip21 {
pub fn parse(uri: &str) -> Result<Self, Nip21Error> {
let body = strip_scheme(uri).ok_or(Nip21Error::InvalidUri)?;
let entity = Nip19Entity::from_bech32(body)?;
Self::try_from(entity)
}
pub fn to_bech32_body(&self) -> Result<String, Nip21Error> {
let body = match self {
Self::Pubkey(pk) => pk.to_bech32()?,
Self::EventId(id) => id.to_bech32()?,
Self::Profile(p) => p.to_bech32()?,
Self::Event(e) => e.to_bech32()?,
Self::Coordinate(c) => c.to_bech32()?,
};
Ok(body)
}
pub fn to_nostr_uri(&self) -> Result<String, Nip21Error> {
let body = self.to_bech32_body()?;
Ok(format!("{SCHEME_PREFIX}{body}"))
}
#[must_use]
pub const fn event_id(&self) -> Option<EventId> {
match self {
Self::EventId(id) => Some(*id),
Self::Event(e) => Some(e.event_id),
Self::Pubkey(_) | Self::Profile(_) | Self::Coordinate(_) => None,
}
}
#[must_use]
pub const fn pubkey(&self) -> Option<PublicKey> {
match self {
Self::Pubkey(pk) => Some(*pk),
Self::Profile(p) => Some(p.public_key),
Self::Coordinate(c) => Some(*c.author()),
Self::EventId(_) | Self::Event(_) => None,
}
}
}
impl From<Nip21> for Nip19Entity {
fn from(value: Nip21) -> Self {
match value {
Nip21::Pubkey(pk) => Self::PublicKey(pk),
Nip21::EventId(id) => Self::EventId(id),
Nip21::Profile(p) => Self::Profile(p),
Nip21::Event(e) => Self::Event(e),
Nip21::Coordinate(c) => Self::Coordinate(c),
}
}
}
impl TryFrom<Nip19Entity> for Nip21 {
type Error = Nip21Error;
fn try_from(value: Nip19Entity) -> Result<Self, Self::Error> {
match value {
Nip19Entity::SecretKey(_) => Err(Nip21Error::SecretKeyRefused),
Nip19Entity::PublicKey(pk) => Ok(Self::Pubkey(pk)),
Nip19Entity::EventId(id) => Ok(Self::EventId(id)),
Nip19Entity::Profile(p) => Ok(Self::Profile(p)),
Nip19Entity::Event(e) => Ok(Self::Event(e)),
Nip19Entity::Coordinate(c) => Ok(Self::Coordinate(c)),
}
}
}
pub trait ToNostrUri: sealed::ToSealed {
fn to_nostr_uri(&self) -> Result<String, Nip21Error>;
}
pub trait FromNostrUri: sealed::FromSealed + Sized {
fn from_nostr_uri(uri: &str) -> Result<Self, Nip21Error>;
}
mod sealed {
use super::{EventId, Nip19Coordinate, Nip19Event, Nip19Profile, Nip21, PublicKey};
pub trait ToSealed {}
pub trait FromSealed {}
impl ToSealed for PublicKey {}
impl ToSealed for EventId {}
impl ToSealed for Nip19Profile {}
impl ToSealed for Nip19Event {}
impl ToSealed for Nip19Coordinate {}
impl ToSealed for Nip21 {}
impl FromSealed for PublicKey {}
impl FromSealed for EventId {}
impl FromSealed for Nip19Profile {}
impl FromSealed for Nip19Event {}
impl FromSealed for Nip19Coordinate {}
impl FromSealed for Nip21 {}
}
fn strip_scheme(uri: &str) -> Option<&str> {
let body = uri.strip_prefix(SCHEME_PREFIX)?;
if body.is_empty() { None } else { Some(body) }
}
macro_rules! impl_to_nostr_uri_via_bech32 {
($($ty:ty),+ $(,)?) => {
$(
impl ToNostrUri for $ty {
fn to_nostr_uri(&self) -> Result<String, Nip21Error> {
let body = ToBech32::to_bech32(self)?;
Ok(format!("{SCHEME_PREFIX}{body}"))
}
}
)+
};
}
macro_rules! impl_from_nostr_uri_via_bech32 {
($($ty:ty),+ $(,)?) => {
$(
impl FromNostrUri for $ty {
fn from_nostr_uri(uri: &str) -> Result<Self, Nip21Error> {
let body = strip_scheme(uri).ok_or(Nip21Error::InvalidUri)?;
Self::from_bech32(body).map_err(Nip21Error::Decode)
}
}
)+
};
}
impl_to_nostr_uri_via_bech32!(
PublicKey,
EventId,
Nip19Profile,
Nip19Event,
Nip19Coordinate
);
impl_from_nostr_uri_via_bech32!(
PublicKey,
EventId,
Nip19Profile,
Nip19Event,
Nip19Coordinate
);
impl ToNostrUri for Nip21 {
fn to_nostr_uri(&self) -> Result<String, Nip21Error> {
Self::to_nostr_uri(self)
}
}
impl FromNostrUri for Nip21 {
fn from_nostr_uri(uri: &str) -> Result<Self, Nip21Error> {
Self::parse(uri)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{EventId, Kind};
use crate::types::RelayUrl;
const FIXTURE_PUBKEY_HEX: &str =
"aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4";
const FIXTURE_NPUB_URI: &str =
"nostr:npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy";
fn fixture_pubkey() -> PublicKey {
PublicKey::parse(FIXTURE_PUBKEY_HEX).expect("fixture hex parses")
}
#[test]
fn pubkey_round_trip_matches_upstream_fixture() {
let pk = fixture_pubkey();
assert_eq!(pk.to_nostr_uri().unwrap(), FIXTURE_NPUB_URI);
assert_eq!(PublicKey::from_nostr_uri(FIXTURE_NPUB_URI).unwrap(), pk);
let parsed = Nip21::parse(FIXTURE_NPUB_URI).unwrap();
assert_eq!(parsed, Nip21::Pubkey(pk));
assert_eq!(parsed.pubkey(), Some(pk));
assert_eq!(parsed.event_id(), None);
assert_eq!(parsed.to_nostr_uri().unwrap(), FIXTURE_NPUB_URI);
}
#[test]
fn profile_round_trip() {
let pk = fixture_pubkey();
let profile = Nip19Profile::new(
pk,
[RelayUrl::parse("wss://relay.damus.io/").expect("fixture relay parses")],
);
let uri = profile.to_nostr_uri().unwrap();
assert!(uri.starts_with("nostr:nprofile"));
let round_trip = Nip19Profile::from_nostr_uri(&uri).unwrap();
assert_eq!(round_trip, profile);
assert_eq!(Nip21::parse(&uri).unwrap(), Nip21::Profile(profile));
}
#[test]
fn event_round_trip_preserves_discriminator_accessors() {
let id = EventId::parse("b2f61aa5ce66cef9f9e3dcbfa9a17b16b6b9d43f7e0a8e2b7c5f1e6f80a7f123")
.expect("fixture event id parses");
let nevent = Nip19Event::new(id)
.with_author(fixture_pubkey())
.with_kind(Kind::TEXT_NOTE)
.with_relays([RelayUrl::parse("wss://relay.damus.io/").unwrap()]);
let uri = nevent.to_nostr_uri().unwrap();
assert!(uri.starts_with("nostr:nevent"));
let parsed = Nip21::parse(&uri).unwrap();
assert_eq!(parsed.event_id(), Some(id));
assert_eq!(parsed.pubkey(), None);
assert!(matches!(parsed, Nip21::Event(_)));
}
#[test]
fn secret_key_is_refused_at_parse() {
let nsec_uri = "nostr:nsec1j4c6269y9w0q2er2xjw8sv2ehyrtfxq3jwgdlxj6qfn8z4gjsq5qfvfk99";
let err = Nip21::parse(nsec_uri).expect_err("nsec URIs are forbidden");
assert!(matches!(err, Nip21Error::SecretKeyRefused));
}
#[test]
fn missing_scheme_is_rejected() {
for bad in [
"npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy",
"nostr:",
] {
let err = Nip21::parse(bad).expect_err("Nip21::parse accepts only `nostr:<bech32>`");
assert!(
matches!(err, Nip21Error::InvalidUri),
"unexpected error for {bad:?}: {err:?}"
);
}
let trait_err =
PublicKey::from_nostr_uri("bolt11:lnbc1…").expect_err("foreign scheme is not NIP-21");
assert!(matches!(trait_err, Nip21Error::InvalidUri));
}
#[test]
fn nip19_entity_bidirectional_conversion() {
let pk = fixture_pubkey();
let as_entity: Nip19Entity = Nip21::Pubkey(pk).into();
assert_eq!(as_entity, Nip19Entity::PublicKey(pk));
let back = Nip21::try_from(as_entity).unwrap();
assert_eq!(back, Nip21::Pubkey(pk));
let sk = crate::SecretKey::parse(
"0000000000000000000000000000000000000000000000000000000000000003",
)
.unwrap();
let err = Nip21::try_from(Nip19Entity::SecretKey(sk))
.expect_err("secret keys must not become NIP-21 values");
assert!(matches!(err, Nip21Error::SecretKeyRefused));
}
}