From 3c720bba5b70199b146f7773a833ac95895c12a1 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto Date: Thu, 12 Mar 2026 08:31:18 +0100 Subject: [PATCH 01/40] nostr: start rework `Tag` - Remove `Tag::from_standardized_without_cell` - Remove `Tag::to_standardized` and `Tag::as_standardized` and add `Tag::standardized` - Update all the related code Ref https://github.com/rust-nostr/nostr/issues/907 Signed-off-by: Yuki Kishimoto --- crates/nostr-relay-builder/src/local/inner.rs | 4 +- .../nostr-relay-builder/src/local/session.rs | 2 +- crates/nostr/src/event/builder.rs | 187 ++++++++---------- crates/nostr/src/event/mod.rs | 24 +-- crates/nostr/src/event/tag/cow.rs | 2 +- crates/nostr/src/event/tag/list.rs | 34 ++-- crates/nostr/src/event/tag/mod.rs | 144 +++++--------- crates/nostr/src/nips/nip17.rs | 17 +- crates/nostr/src/nips/nip22.rs | 159 +++++++-------- crates/nostr/src/nips/nip25.rs | 22 +-- crates/nostr/src/nips/nip34.rs | 66 +++---- crates/nostr/src/nips/nip42.rs | 2 +- crates/nostr/src/nips/nip51.rs | 8 +- crates/nostr/src/nips/nip53.rs | 94 ++++----- crates/nostr/src/nips/nip57.rs | 26 +-- crates/nostr/src/nips/nip58.rs | 10 +- crates/nostr/src/nips/nip60.rs | 2 +- crates/nostr/src/nips/nip65.rs | 22 +-- crates/nostr/src/nips/nip88.rs | 18 +- crates/nostr/src/nips/nip94.rs | 78 ++++---- crates/nostr/src/nips/nip98.rs | 37 ++-- crates/nostr/src/nips/nipc0.rs | 2 +- database/nostr-database-test-suite/src/lib.rs | 4 +- database/nostr-database/src/ext.rs | 17 +- database/nostr-lmdb/src/store/lmdb/index.rs | 6 +- database/nostr-lmdb/src/store/lmdb/mod.rs | 16 +- database/nostr-memory/src/store.rs | 13 +- database/nostr-sqlite/src/store.rs | 22 +-- gossip/nostr-gossip-memory/src/store.rs | 8 +- gossip/nostr-gossip-sqlite/src/store.rs | 24 +-- gossip/nostr-gossip-test-suite/src/lib.rs | 2 +- sdk/src/client/api/send_event.rs | 3 +- sdk/src/client/gossip/resolver.rs | 10 +- 33 files changed, 445 insertions(+), 640 deletions(-) diff --git a/crates/nostr-relay-builder/src/local/inner.rs b/crates/nostr-relay-builder/src/local/inner.rs index 4b8de7061..864ff725f 100644 --- a/crates/nostr-relay-builder/src/local/inner.rs +++ b/crates/nostr-relay-builder/src/local/inner.rs @@ -541,7 +541,7 @@ impl InnerLocalRelay { // Check mode if let LocalRelayBuilderMode::PublicKey(pk) = self.mode { let authored: bool = event.pubkey == pk; - let tagged: bool = event.tags.public_keys().any(|p| p == &pk); + let tagged: bool = event.tags.public_keys().any(|p| p == pk); if !authored && !tagged { return send_msg( @@ -965,7 +965,7 @@ impl InnerLocalRelay { let now = Timestamp::now(); // Add events json_msgs.extend(events.into_iter().filter_map(|event| { - if event.is_expired_at(&now) { + if event.is_expired_at(now) { return None; } Some( diff --git a/crates/nostr-relay-builder/src/local/session.rs b/crates/nostr-relay-builder/src/local/session.rs index f33df9a7c..e8efd1856 100644 --- a/crates/nostr-relay-builder/src/local/session.rs +++ b/crates/nostr-relay-builder/src/local/session.rs @@ -46,7 +46,7 @@ impl Nip42Session { match event.tags.challenge() { Some(challenge) => { // Tried to remove challenge but wasn't in the set: return false. - if !self.challenges.remove(challenge) { + if !self.challenges.remove(&challenge) { return Err(String::from("received invalid challenge")); } diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index f227948f4..91431b197 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -418,7 +418,7 @@ impl EventBuilder { Some(root) => { // Check if root event is different from reply event if root.id != reply_to.id { - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { + tags.push(Tag::from_standardized(TagStandard::Event { event_id: reply_to.id, relay_url: relay_url.clone(), marker: Some(Marker::Reply), @@ -429,7 +429,7 @@ impl EventBuilder { } // ID and author - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { + tags.push(Tag::from_standardized(TagStandard::Event { event_id: root.id, relay_url, marker: Some(Marker::Root), @@ -440,7 +440,7 @@ impl EventBuilder { } // No root tag, add only reply_to tags None => { - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { + tags.push(Tag::from_standardized(TagStandard::Event { event_id: reply_to.id, relay_url, marker: Some(Marker::Reply), @@ -505,7 +505,7 @@ impl EventBuilder { I: IntoIterator, { let tags = contacts.into_iter().map(|contact| { - Tag::from_standardized_without_cell(TagStandard::PublicKey { + Tag::from_standardized(TagStandard::PublicKey { public_key: contact.public_key, relay_url: contact.relay_url, alias: contact.alias, @@ -522,7 +522,7 @@ impl EventBuilder { pub fn opentimestamps(event_id: EventId, relay_url: Option) -> Result { let ots: String = nostr_ots::timestamp_event(&event_id.to_hex())?; Ok( - Self::new(Kind::OpenTimestamps, ots).tags([Tag::from_standardized_without_cell( + Self::new(Kind::OpenTimestamps, ots).tags([Tag::from_standardized( TagStandard::Event { event_id, relay_url, @@ -547,7 +547,7 @@ impl EventBuilder { if event.kind == Kind::TextNote { Self::new(Kind::Repost, content).tags([ - Tag::from_standardized_without_cell(TagStandard::Event { + Tag::from_standardized(TagStandard::Event { event_id: event.id, relay_url, marker: None, @@ -562,10 +562,10 @@ impl EventBuilder { .tag_maybe( event .coordinate() - .map(|c| Tag::coordinate(c.into_owned(), relay_url.clone())), + .map(|c| Tag::coordinate(c, relay_url.clone())), ) .tags([ - Tag::from_standardized_without_cell(TagStandard::Event { + Tag::from_standardized(TagStandard::Event { event_id: event.id, relay_url, marker: None, @@ -574,7 +574,7 @@ impl EventBuilder { uppercase: false, }), Tag::public_key(event.pubkey), - Tag::from_standardized_without_cell(TagStandard::Kind { + Tag::from_standardized(TagStandard::Kind { kind: event.kind, uppercase: false, }), @@ -687,15 +687,15 @@ impl EventBuilder { relay_url: Option, metadata: &Metadata, ) -> Self { - Self::new(Kind::ChannelMetadata, metadata.as_json()).tags([ - Tag::from_standardized_without_cell(TagStandard::Event { + Self::new(Kind::ChannelMetadata, metadata.as_json()).tags([Tag::from_standardized( + TagStandard::Event { event_id: channel_id, relay_url, marker: None, public_key: None, uppercase: false, - }), - ]) + }, + )]) } /// Channel message @@ -706,7 +706,7 @@ impl EventBuilder { where S: Into, { - Self::new(Kind::ChannelMessage, content).tags([Tag::from_standardized_without_cell( + Self::new(Kind::ChannelMessage, content).tags([Tag::from_standardized( TagStandard::Event { event_id: channel_id, relay_url: Some(relay_url), @@ -756,8 +756,8 @@ impl EventBuilder { S: Into, { Self::new(Kind::Authentication, "").tags([ - Tag::from_standardized_without_cell(TagStandard::Challenge(challenge.into())), - Tag::from_standardized_without_cell(TagStandard::Relay(relay)), + Tag::from_standardized(TagStandard::Challenge(challenge.into())), + Tag::from_standardized(TagStandard::Relay(relay)), ]) } @@ -809,7 +809,7 @@ impl EventBuilder { where S: Into, { - Self::new(Kind::LiveEventMessage, content).tag(Tag::from_standardized_without_cell( + Self::new(Kind::LiveEventMessage, content).tag(Tag::from_standardized( TagStandard::Coordinate { coordinate: Coordinate::new(Kind::LiveEvent, live_event_host) .identifier(live_event_id), @@ -878,13 +878,13 @@ impl EventBuilder { S2: Into, { let mut tags: Vec = vec![ - Tag::from_standardized_without_cell(TagStandard::Bolt11(bolt11.into())), - Tag::from_standardized_without_cell(TagStandard::Description(zap_request.as_json())), + Tag::from_standardized(TagStandard::Bolt11(bolt11.into())), + Tag::from_standardized(TagStandard::Description(zap_request.as_json())), ]; // add preimage tag if provided if let Some(pre_image_tag) = preimage { - tags.push(Tag::from_standardized_without_cell(TagStandard::Preimage( + tags.push(Tag::from_standardized(TagStandard::Preimage( pre_image_tag.into(), ))) } @@ -938,14 +938,12 @@ impl EventBuilder { } // add P tag - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKey { - public_key: zap_request.pubkey, - relay_url: None, - alias: None, - uppercase: true, - }, - )); + tags.push(Tag::from_standardized(TagStandard::PublicKey { + public_key: zap_request.pubkey, + relay_url: None, + alias: None, + uppercase: true, + })); Self::new(Kind::ZapReceipt, "").tags(tags) } @@ -989,24 +987,22 @@ impl EventBuilder { // Set name tag if let Some(name) = name { - tags.push(Tag::from_standardized_without_cell(TagStandard::Name( - name.into(), - ))); + tags.push(Tag::from_standardized(TagStandard::Name(name.into()))); } // Set description tag if let Some(description) = description { - tags.push(Tag::from_standardized_without_cell( - TagStandard::Description(description.into()), - )); + tags.push(Tag::from_standardized(TagStandard::Description( + description.into(), + ))); } // Set image tag if let Some(image) = image { let image_tag = if let Some(dimensions) = image_dimensions { - Tag::from_standardized_without_cell(TagStandard::Image(image, Some(dimensions))) + Tag::from_standardized(TagStandard::Image(image, Some(dimensions))) } else { - Tag::from_standardized_without_cell(TagStandard::Image(image, None)) + Tag::from_standardized(TagStandard::Image(image, None)) }; tags.push(image_tag); } @@ -1014,9 +1010,9 @@ impl EventBuilder { // Set thumbnail tags for (thumb, dimensions) in thumbnails.into_iter() { let thumb_tag = if let Some(dimensions) = dimensions { - Tag::from_standardized_without_cell(TagStandard::Thumb(thumb, Some(dimensions))) + Tag::from_standardized(TagStandard::Thumb(thumb, Some(dimensions))) } else { - Tag::from_standardized_without_cell(TagStandard::Thumb(thumb, None)) + Tag::from_standardized(TagStandard::Thumb(thumb, None)) }; tags.push(thumb_tag); } @@ -1034,7 +1030,7 @@ impl EventBuilder { let badge_id = badge_definition .tags .iter() - .find_map(|t| match t.as_standardized() { + .find_map(|t| match t.standardized() { Some(TagStandard::Identifier(id)) => Some(id), _ => None, }) @@ -1044,14 +1040,12 @@ impl EventBuilder { let mut tags = Vec::with_capacity(1); // Add identity tag - tags.push(Tag::from_standardized_without_cell( - TagStandard::Coordinate { - coordinate: Coordinate::new(Kind::BadgeDefinition, badge_definition.pubkey) - .identifier(badge_id), - relay_url: None, - uppercase: false, - }, - )); + tags.push(Tag::from_standardized(TagStandard::Coordinate { + coordinate: Coordinate::new(Kind::BadgeDefinition, badge_definition.pubkey) + .identifier(badge_id), + relay_url: None, + uppercase: false, + })); // Add awarded public keys tags.extend(awarded_public_keys.into_iter().map(Tag::public_key)); @@ -1078,8 +1072,8 @@ impl EventBuilder { } for award in badge_awards.iter() { - if !award.tags.iter().any(|t| match t.as_standardized() { - Some(TagStandard::PublicKey { public_key, .. }) => public_key == pubkey_awarded, + if !award.tags.iter().any(|t| match t.standardized() { + Some(TagStandard::PublicKey { public_key, .. }) => public_key == *pubkey_awarded, _ => false, }) { return Err(Error::NIP58(nip58::Error::BadgeAwardsLackAwardedPublicKey)); @@ -1097,16 +1091,16 @@ impl EventBuilder { let mut tags: Vec = vec![id_tag]; let badge_definitions_identifiers = badge_definitions.iter().filter_map(|event| { - let id: &str = event.tags.identifier()?; + let id: String = event.tags.identifier()?; Some((event, id)) }); let badge_awards_identifiers = badge_awards.iter().filter_map(|event| { let (_, relay_url) = - nip58::extract_awarded_public_key(event.tags.as_slice(), pubkey_awarded)?; - let (id, a_tag) = event.tags.iter().find_map(|t| match t.as_standardized() { + nip58::extract_awarded_public_key(event.tags.as_slice(), *pubkey_awarded)?; + let (id, a_tag) = event.tags.iter().find_map(|t| match t.standardized() { Some(TagStandard::Coordinate { coordinate, .. }) => { - Some((&coordinate.identifier, t)) + Some((coordinate.identifier, t)) } _ => None, })?; @@ -1124,14 +1118,13 @@ impl EventBuilder { ((_, identifier), (badge_award_event, badge_id, a_tag, relay_url)) if badge_id == identifier => { - let badge_award_event_tag: Tag = - Tag::from_standardized_without_cell(TagStandard::Event { - event_id: badge_award_event.id, - relay_url: relay_url.clone(), - marker: None, - public_key: None, - uppercase: false, - }); + let badge_award_event_tag: Tag = Tag::from_standardized(TagStandard::Event { + event_id: badge_award_event.id, + relay_url: relay_url.clone(), + marker: None, + public_key: None, + uppercase: false, + }); tags.extend_from_slice(&[a_tag.clone(), badge_award_event_tag]); } _ => {} @@ -1197,8 +1190,8 @@ impl EventBuilder { tags.extend_from_slice(&[ Tag::event(job_request.id), Tag::public_key(job_request.pubkey), - Tag::from_standardized_without_cell(TagStandard::Request(job_request)), - Tag::from_standardized_without_cell(TagStandard::Amount { millisats, bolt11 }), + Tag::from_standardized(TagStandard::Request(job_request)), + Tag::from_standardized(TagStandard::Amount { millisats, bolt11 }), ]); Ok(Self::new(kind, payload).tags(tags)) @@ -1212,7 +1205,7 @@ impl EventBuilder { tags.push(Tag::event(data.job_request_id)); tags.push(Tag::public_key(data.customer_public_key)); - tags.push(Tag::from_standardized_without_cell( + tags.push(Tag::from_standardized( TagStandard::DataVendingMachineStatus { status: data.status, extra_info: data.extra_info, @@ -1220,7 +1213,7 @@ impl EventBuilder { )); if let Some(millisats) = data.amount_msat { - tags.push(Tag::from_standardized_without_cell(TagStandard::Amount { + tags.push(Tag::from_standardized(TagStandard::Amount { millisats, bolt11: data.bolt11, })); @@ -1498,7 +1491,7 @@ impl EventBuilder { Self::new(Kind::BlossomServerList, "").tags( servers .into_iter() - .map(|s| Tag::from_standardized_without_cell(TagStandard::Server(s))), + .map(|s| Tag::from_standardized(TagStandard::Server(s))), ) } @@ -1535,7 +1528,7 @@ impl EventBuilder { Self::new(Kind::BlockedRelays, "").tags( relay .into_iter() - .map(|r| Tag::from_standardized_without_cell(TagStandard::Relay(r))), + .map(|r| Tag::from_standardized(TagStandard::Relay(r))), ) } @@ -1550,7 +1543,7 @@ impl EventBuilder { Self::new(Kind::SearchRelays, "").tags( relay .into_iter() - .map(|r| Tag::from_standardized_without_cell(TagStandard::Relay(r))), + .map(|r| Tag::from_standardized(TagStandard::Relay(r))), ) } @@ -1600,7 +1593,7 @@ impl EventBuilder { tags.into_iter().chain( relays .into_iter() - .map(|r| Tag::from_standardized_without_cell(TagStandard::Relay(r))), + .map(|r| Tag::from_standardized(TagStandard::Relay(r))), ), ) } @@ -1669,11 +1662,13 @@ impl EventBuilder { I: IntoIterator, { let tags: Vec = vec![Tag::identifier(identifier)]; - Self::new(Kind::EmojiSet, "").tags(tags.into_iter().chain(emojis.into_iter().map( - |(s, url)| { - Tag::from_standardized_without_cell(TagStandard::Emoji { shortcode: s, url }) - }, - ))) + Self::new(Kind::EmojiSet, "").tags( + tags.into_iter().chain( + emojis.into_iter().map(|(s, url)| { + Tag::from_standardized(TagStandard::Emoji { shortcode: s, url }) + }), + ), + ) } /// Label @@ -1687,8 +1682,8 @@ impl EventBuilder { let namespace: String = namespace.into(); let label: String = label.into(); Self::new(Kind::Label, "").tags([ - Tag::from_standardized_without_cell(TagStandard::LabelNamespace(namespace.clone())), - Tag::from_standardized_without_cell(TagStandard::Label { + Tag::from_standardized(TagStandard::LabelNamespace(namespace.clone())), + Tag::from_standardized(TagStandard::Label { value: label, namespace: Some(namespace), }), @@ -1826,13 +1821,11 @@ impl EventBuilder { } Ok( - Self::new(Kind::ChatMessage, content).tag(Tag::from_standardized_without_cell( - TagStandard::Quote { - event_id: reply_to.id, - relay_url, - public_key: Some(reply_to.pubkey), - }, - )), + Self::new(Kind::ChatMessage, content).tag(Tag::from_standardized(TagStandard::Quote { + event_id: reply_to.id, + relay_url, + public_key: Some(reply_to.pubkey), + })), ) } @@ -1846,7 +1839,7 @@ impl EventBuilder { { let mut builder = Self::new(Kind::Thread, content); if let Some(t) = title { - builder = builder.tag(Tag::from_standardized_without_cell(TagStandard::Title(t))); + builder = builder.tag(Tag::from_standardized(TagStandard::Title(t))); } builder } @@ -1860,14 +1853,14 @@ impl EventBuilder { S: Into, { let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Event { + Tag::from_standardized(TagStandard::Event { event_id: reply_to.id, relay_url, marker: None, public_key: Some(reply_to.pubkey), uppercase: true, }), - Tag::from_standardized_without_cell(TagStandard::Kind { + Tag::from_standardized(TagStandard::Kind { kind: Kind::Thread, uppercase: true, }), @@ -2181,15 +2174,9 @@ mod tests { .sign_with_keys(&reply_keys) .unwrap(); assert_eq!(reply.tags.public_keys().count(), 1); // Root author - assert_eq!( - reply.tags.public_keys().copied().next().unwrap(), - root_event.pubkey - ); + assert_eq!(reply.tags.public_keys().next().unwrap(), root_event.pubkey); assert_eq!(reply.tags.event_ids().count(), 1); // Root event ID - assert_eq!( - reply.tags.event_ids().copied().next().unwrap(), - root_event.id - ); + assert_eq!(reply.tags.event_ids().next().unwrap(), root_event.id); // Build reply of reply let other_keys = Keys::generate(); @@ -2199,13 +2186,13 @@ mod tests { .unwrap(); assert_eq!(reply_of_reply.tags.public_keys().count(), 2); // Reply + root author - let mut pks = reply_of_reply.tags.public_keys().copied(); + let mut pks = reply_of_reply.tags.public_keys(); assert_eq!(pks.next().unwrap(), reply.pubkey); assert_eq!(pks.next().unwrap(), root_event.pubkey); assert_eq!(reply_of_reply.tags.event_ids().count(), 2); // Reply + root event IDs - let mut ids = reply_of_reply.tags.event_ids().copied(); + let mut ids = reply_of_reply.tags.event_ids(); assert_eq!(ids.next().unwrap(), reply.id); assert_eq!(ids.next().unwrap(), root_event.id); } @@ -2227,7 +2214,7 @@ mod tests { .tags .find_standardized(TagKind::single_letter(Alphabet::A, false)) .unwrap(), - &TagStandard::Coordinate { + TagStandard::Coordinate { coordinate: Coordinate::new(replaceable.kind, replaceable.pubkey), relay_url: None, uppercase: false @@ -2252,7 +2239,7 @@ mod tests { .tags .find_standardized(TagKind::single_letter(Alphabet::A, false)) .unwrap(), - &TagStandard::Coordinate { + TagStandard::Coordinate { coordinate: Coordinate::new(addressable.kind, addressable.pubkey) .identifier("lorem"), relay_url: None, @@ -2277,7 +2264,7 @@ mod tests { assert_eq!(repost.kind, Kind::Repost); assert_eq!(repost.content, note.as_json()); assert_eq!( - repost.tags[0].clone().to_standardized().unwrap(), + repost.tags[0].standardized().unwrap(), TagStandard::Event { event_id: note.id, relay_url: Some(relay_url), @@ -2287,7 +2274,7 @@ mod tests { } ); assert_eq!( - repost.tags[1].clone().to_standardized().unwrap(), + repost.tags[1].standardized().unwrap(), TagStandard::PublicKey { public_key: note.pubkey, relay_url: None, diff --git a/crates/nostr/src/event/mod.rs b/crates/nostr/src/event/mod.rs index 8aa379689..3b213429f 100644 --- a/crates/nostr/src/event/mod.rs +++ b/crates/nostr/src/event/mod.rs @@ -32,7 +32,7 @@ pub use self::tag::{Tag, TagKind, TagStandard, Tags}; pub use self::unsigned::UnsignedEvent; #[cfg(feature = "std")] use crate::SECP256K1; -use crate::nips::nip01::CoordinateBorrow; +use crate::nips::nip01::Coordinate; use crate::nips::nip19::{self, Nip19Event, ToBech32}; use crate::nips::nip21::ToNostrUri; use crate::{JsonUtil, Metadata, PublicKey, Timestamp}; @@ -226,7 +226,7 @@ impl Event { #[cfg(feature = "std")] pub fn is_expired(&self) -> bool { let now: Timestamp = Timestamp::now(); - self.is_expired_at(&now) + self.is_expired_at(now) } /// Returns `true` if the event has an expiration tag that is expired. @@ -234,7 +234,7 @@ impl Event { /// /// #[inline] - pub fn is_expired_at(&self, now: &Timestamp) -> bool { + pub fn is_expired_at(&self, now: Timestamp) -> bool { if let Some(timestamp) = self.tags.expiration() { return timestamp < now; } @@ -244,12 +244,12 @@ impl Event { /// Get the coordinate of this event /// /// Return a coordinate only if the event kind is `replaceable` or `addressable`. - pub fn coordinate(&self) -> Option> { + pub fn coordinate(&self) -> Option { if self.kind.is_replaceable() || self.kind.is_addressable() { - return Some(CoordinateBorrow { - kind: &self.kind, - public_key: &self.pubkey, - identifier: self.tags.identifier(), + return Some(Coordinate { + kind: self.kind, + public_key: self.pubkey, + identifier: self.tags.identifier().unwrap_or_default(), }); } @@ -505,13 +505,13 @@ mod tests { let event = Event::from_json(json).unwrap(); assert_eq!( event.coordinate(), - Some(CoordinateBorrow { - kind: &Kind::Metadata, - public_key: &PublicKey::from_hex( + Some(Coordinate { + kind: Kind::Metadata, + public_key: PublicKey::from_hex( "2f35aaff0c870f0510a8bed198e1f8c35e95c996148f2d0c0fb1825b05b8dd35" ) .unwrap(), - identifier: None, + identifier: String::new(), }) ); } diff --git a/crates/nostr/src/event/tag/cow.rs b/crates/nostr/src/event/tag/cow.rs index a12281c02..986674b2d 100644 --- a/crates/nostr/src/event/tag/cow.rs +++ b/crates/nostr/src/event/tag/cow.rs @@ -59,7 +59,7 @@ impl<'a> CowTag<'a> { /// Into owned tag pub fn into_owned(self) -> Tag { let buf: Vec = self.buf.into_iter().map(|t| t.into_owned()).collect(); - Tag::new_with_empty_cell(buf) + Tag::new(buf) } /// Get inner value diff --git a/crates/nostr/src/event/tag/list.rs b/crates/nostr/src/event/tag/list.rs index a9cceaed1..0326c4c83 100644 --- a/crates/nostr/src/event/tag/list.rs +++ b/crates/nostr/src/event/tag/list.rs @@ -383,8 +383,8 @@ impl Tags { /// Get the first tag that match [`TagKind`] and that is standardized. #[inline] - pub fn find_standardized(&self, kind: TagKind) -> Option<&TagStandard> { - self.find(kind).and_then(|t| t.as_standardized()) + pub fn find_standardized(&self, kind: TagKind) -> Option { + self.find(kind).and_then(|t| t.standardized()) } /// Filter tags that match [`TagKind`]. @@ -398,8 +398,8 @@ impl Tags { pub fn filter_standardized<'a>( &'a self, kind: TagKind<'a>, - ) -> impl Iterator { - self.filter(kind).filter_map(|t| t.as_standardized()) + ) -> impl Iterator + 'a { + self.filter(kind).filter_map(|t| t.standardized()) } /// Get as slice of tags @@ -416,7 +416,7 @@ impl Tags { /// Extract identifier (`d` tag), if exists. #[inline] - pub fn identifier(&self) -> Option<&str> { + pub fn identifier(&self) -> Option { match self.find_standardized(TagKind::d())? { TagStandard::Identifier(identifier) => Some(identifier), _ => None, @@ -426,7 +426,7 @@ impl Tags { /// Get [`Timestamp`] expiration, if exists. /// /// - pub fn expiration(&self) -> Option<&Timestamp> { + pub fn expiration(&self) -> Option { match self.find_standardized(TagKind::Expiration)? { TagStandard::Expiration(timestamp) => Some(timestamp), _ => None, @@ -435,7 +435,7 @@ impl Tags { /// Extract NIP42 challenge, if exists. #[inline] - pub fn challenge(&self) -> Option<&str> { + pub fn challenge(&self) -> Option { match self.find_standardized(TagKind::Challenge)? { TagStandard::Challenge(challenge) => Some(challenge), _ => None, @@ -446,7 +446,7 @@ impl Tags { /// /// This method extract only [`TagStandard::PublicKey`], [`TagStandard::PublicKeyReport`] and [`TagStandard::PublicKeyLiveEvent`] variants. #[inline] - pub fn public_keys(&self) -> impl Iterator { + pub fn public_keys(&self) -> impl Iterator + '_ { self.filter_standardized(TagKind::p()) .filter_map(|t| match t { TagStandard::PublicKey { public_key, .. } => Some(public_key), @@ -460,7 +460,7 @@ impl Tags { /// /// This method extract only [`TagStandard::Event`] and [`TagStandard::EventReport`] variants. #[inline] - pub fn event_ids(&self) -> impl Iterator { + pub fn event_ids(&self) -> impl Iterator + '_ { self.filter_standardized(TagKind::e()) .filter_map(|t| match t { TagStandard::Event { event_id, .. } => Some(event_id), @@ -473,7 +473,7 @@ impl Tags { /// /// This method extract only [`TagStandard::Coordinate`] variant. #[inline] - pub fn coordinates(&self) -> impl Iterator { + pub fn coordinates(&self) -> impl Iterator + '_ { self.filter_standardized(TagKind::a()) .filter_map(|t| match t { TagStandard::Coordinate { coordinate, .. } => Some(coordinate), @@ -485,10 +485,10 @@ impl Tags { /// /// This method extract only [`TagStandard::Hashtag`] variant. #[inline] - pub fn hashtags(&self) -> impl Iterator { + pub fn hashtags(&self) -> impl Iterator + '_ { self.filter_standardized(TagKind::t()) .filter_map(|t| match t { - TagStandard::Hashtag(hashtag) => Some(hashtag.as_ref()), + TagStandard::Hashtag(hashtag) => Some(hashtag), _ => None, }) } @@ -588,14 +588,14 @@ mod tests { .filter(|t| t.content() == Some("3")) .collect(); assert_eq!(tags.len(), 1); - assert_eq!(tags.identifier(), Some("3")); + assert_eq!(tags.identifier(), Some(String::from("3"))); } #[test] fn test_extract_d_tag() { let json = r#"{"id":"3dfdbb371de782f51812dc4809ea1104d80e143cec1091a4be07f518ef09e3d7","pubkey":"b8aef32a5421205c1f89ad09e2d93873df68a8611b247f62af005655eadc0efb","created_at":1728728536,"kind":30000,"sig":"0395c41fd95d52b534eaa29c82cd9437130cf63e67117b1587914375fdfb878137287a1d15653161f91ea919afb06358784217409a9ff0323261f683b2936829","content":"older_param_replaceable","tags":[["d","1"]]}"#; let event = Event::from_json(json).unwrap(); - assert_eq!(event.tags.identifier(), Some("1")); + assert_eq!(event.tags.identifier(), Some(String::from("1"))); } #[test] @@ -614,14 +614,14 @@ mod tests { EventId::from_hex("2be17aa3031bdcb006f0fce80c146dea9c1c0268b0af2398bb673365c6444d45") .unwrap(); - let long_p_tag_1 = Tag::from_standardized_without_cell(TagStandard::PublicKey { + let long_p_tag_1 = Tag::from_standardized(TagStandard::PublicKey { public_key: pubkey1, relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), uppercase: false, alias: None, }); - let long_e_tag_2 = Tag::from_standardized_without_cell(TagStandard::Event { + let long_e_tag_2 = Tag::from_standardized(TagStandard::Event { event_id: event2, relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), marker: None, @@ -707,7 +707,7 @@ mod benches { for pk in pubkeys.into_iter() { // Push long p tag - let long_p_tag = Tag::from_standardized_without_cell(TagStandard::PublicKey { + let long_p_tag = Tag::from_standardized(TagStandard::PublicKey { public_key: pk, relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), uppercase: false, diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 741ef82a5..3f3193bce 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -6,14 +6,10 @@ use alloc::string::{String, ToString}; use alloc::vec::{IntoIter, Vec}; -#[cfg(not(feature = "std"))] -use core::cell::OnceCell; use core::cmp::Ordering; use core::fmt; use core::hash::{Hash, Hasher}; use core::ops::{Index, IndexMut}; -#[cfg(feature = "std")] -use std::sync::OnceLock as OnceCell; use serde::de::Error as DeserializerError; use serde::ser::SerializeSeq; @@ -42,7 +38,6 @@ use crate::{ImageDimensions, PublicKey, RelayUrl, SingleLetterTag, Timestamp}; #[derive(Clone)] pub struct Tag { buf: Vec, - standardized: OnceCell>, } impl fmt::Debug for Tag { @@ -93,26 +88,12 @@ impl IndexMut for Tag { impl Tag { #[inline] - fn new(buf: Vec, standardized: Option) -> Self { - Self { - buf, - standardized: OnceCell::from(standardized), - } - } + fn new(buf: Vec) -> Self { + // The tag must not be empty! + assert!(!buf.is_empty()); - #[inline] - fn new_with_empty_cell(buf: Vec) -> Self { - Self { - buf, - standardized: OnceCell::new(), - } - } - - #[inline] - fn erase_standardized(&mut self) { - if self.standardized.get().is_some() { - self.standardized = OnceCell::new(); - } + // Construct + Self { buf } } /// Parse tag @@ -132,19 +113,13 @@ impl Tag { } // Construct without an empty cell - Ok(Self::new_with_empty_cell(tag)) + Ok(Self::new(tag)) } /// Construct from standardized tag #[inline] pub fn from_standardized(standardized: TagStandard) -> Self { - Self::new(standardized.clone().to_vec(), Some(standardized)) - } - - /// Construct from standardized tag without initialize cell (avoid a clone) - #[inline] - pub fn from_standardized_without_cell(standardized: TagStandard) -> Self { - Self::new_with_empty_cell(standardized.to_vec()) + Self::new(standardized.to_vec()) } /// Get tag kind @@ -161,7 +136,7 @@ impl Tag { self.buf.get(1).map(|s| s.as_str()) } - /// Get [SingleLetterTag] + /// Get [`SingleLetterTag`] #[inline] pub fn single_letter_tag(&self) -> Option { match self.kind() { @@ -170,24 +145,15 @@ impl Tag { } } - /// Get reference of standardized tag + /// Attempt to parse as a standardized tag #[inline] - pub fn as_standardized(&self) -> Option<&TagStandard> { - self.standardized - .get_or_init(|| TagStandard::parse(self.as_slice()).ok()) - .as_ref() - } - - /// Consume tag and get standardized tag - #[inline] - pub fn to_standardized(self) -> Option { - match self.standardized.into_inner() { - Some(inner) => inner, - None => TagStandard::parse(&self.buf).ok(), - } + pub fn standardized(&self) -> Option { + TagStandard::parse(self.as_slice()).ok() } /// Get tag len + /// + /// This will never return zero. #[inline] #[allow(clippy::len_without_is_empty)] pub fn len(&self) -> usize { @@ -197,16 +163,11 @@ impl Tag { /// Appends a value to the back of the [`Tag`]. /// /// Check [`Vec::push`] doc to learn more. - /// - /// This erases the [`TagStandard`] cell, if any. + #[inline] pub fn push(&mut self, value: S) where S: Into, { - // Erase indexes - self.erase_standardized(); - - // Append self.buf.push(value.into()); } @@ -214,17 +175,13 @@ impl Tag { /// If the [`Tag`] has only **one** value, returns `None`, since it can't be empty. /// /// Check [`Vec::pop`] doc to learn more. - /// - /// This erases the [`TagStandard`] cell, if any. + #[inline] pub fn pop(&mut self) -> Option { // The tag must have at least one value! if self.buf.len() <= 1 { return None; } - // Erase indexes - self.erase_standardized(); - // Pop last item self.buf.pop() } @@ -239,8 +196,6 @@ impl Tag { /// Returns `false` if `index > len`. /// /// Check [`Vec::insert`] doc to learn more. - /// - /// This erases the [`TagStandard`] cell, if any. pub fn insert(&mut self, index: usize, value: S) -> bool where S: Into, @@ -257,9 +212,6 @@ impl Tag { return false; } - // Erase indexes - self.erase_standardized(); - // Insert at position self.buf.insert(index, value); @@ -270,17 +222,12 @@ impl Tag { /// Extends the collection. /// /// Check [`Vec::extend`] doc to learn more. - /// - /// This erases the [`TagStandard`] cell, if any. + #[inline] pub fn extend(&mut self, iter: I) where I: IntoIterator, S: Into, { - // Erase standardized tag - self.erase_standardized(); - - // Extend list self.buf.extend(iter.into_iter().map(|v| v.into())); } @@ -301,7 +248,7 @@ impl Tag { /// #[inline] pub fn event(event_id: EventId) -> Self { - Self::from_standardized_without_cell(TagStandard::event(event_id)) + Self::from_standardized(TagStandard::event(event_id)) } /// Compose `["p", ""]` tag @@ -309,7 +256,7 @@ impl Tag { /// #[inline] pub fn public_key(public_key: PublicKey) -> Self { - Self::from_standardized_without_cell(TagStandard::public_key(public_key)) + Self::from_standardized(TagStandard::public_key(public_key)) } /// Compose `["d", ""]` tag @@ -320,7 +267,7 @@ impl Tag { where T: Into, { - Self::from_standardized_without_cell(TagStandard::Identifier(identifier.into())) + Self::from_standardized(TagStandard::Identifier(identifier.into())) } /// Compose `["a", "", ""]` tag @@ -328,7 +275,7 @@ impl Tag { /// #[inline] pub fn coordinate(coordinate: Coordinate, relay_url: Option) -> Self { - Self::from_standardized_without_cell(TagStandard::Coordinate { + Self::from_standardized(TagStandard::Coordinate { coordinate, relay_url, uppercase: false, @@ -340,7 +287,7 @@ impl Tag { /// #[inline] pub fn pow(nonce: u128, difficulty: u8) -> Self { - Self::from_standardized_without_cell(TagStandard::POW { nonce, difficulty }) + Self::from_standardized(TagStandard::POW { nonce, difficulty }) } /// Construct `["client", ""]` tag @@ -350,7 +297,7 @@ impl Tag { where S: Into, { - Self::from_standardized_without_cell(TagStandard::Client { + Self::from_standardized(TagStandard::Client { name: name.into(), address: None, }) @@ -361,7 +308,7 @@ impl Tag { /// #[inline] pub fn expiration(timestamp: Timestamp) -> Self { - Self::from_standardized_without_cell(TagStandard::Expiration(timestamp)) + Self::from_standardized(TagStandard::Expiration(timestamp)) } /// Compose `["e", "", ""]` tag @@ -369,7 +316,7 @@ impl Tag { /// #[inline] pub fn event_report(event_id: EventId, report: Report) -> Self { - Self::from_standardized_without_cell(TagStandard::EventReport(event_id, report)) + Self::from_standardized(TagStandard::EventReport(event_id, report)) } /// Compose `["p", "", ""]` tag @@ -377,7 +324,7 @@ impl Tag { /// #[inline] pub fn public_key_report(public_key: PublicKey, report: Report) -> Self { - Self::from_standardized_without_cell(TagStandard::PublicKeyReport(public_key, report)) + Self::from_standardized(TagStandard::PublicKeyReport(public_key, report)) } /// Compose `["r", "", ""]` tag @@ -385,7 +332,7 @@ impl Tag { /// #[inline] pub fn relay_metadata(relay_url: RelayUrl, metadata: Option) -> Self { - Self::from_standardized_without_cell(TagStandard::RelayMetadata { + Self::from_standardized(TagStandard::RelayMetadata { relay_url, metadata, }) @@ -396,7 +343,7 @@ impl Tag { /// JSON: `["relay", ""]` #[inline] pub fn relay(url: RelayUrl) -> Self { - Self::from_standardized_without_cell(TagStandard::Relay(url)) + Self::from_standardized(TagStandard::Relay(url)) } /// Relay URLs @@ -407,7 +354,7 @@ impl Tag { where I: IntoIterator, { - Self::from_standardized_without_cell(TagStandard::Relays(urls.into_iter().collect())) + Self::from_standardized(TagStandard::Relays(urls.into_iter().collect())) } /// All relays @@ -417,7 +364,7 @@ impl Tag { /// #[inline] pub fn all_relays() -> Self { - Self::from_standardized_without_cell(TagStandard::AllRelays) + Self::from_standardized(TagStandard::AllRelays) } /// Repository head @@ -430,7 +377,7 @@ impl Tag { where S: Into, { - Self::from_standardized_without_cell(TagStandard::GitHead(branch_name.into())) + Self::from_standardized(TagStandard::GitHead(branch_name.into())) } /// Compose `["t", ""]` tag @@ -441,7 +388,7 @@ impl Tag { where T: AsRef, { - Self::from_standardized_without_cell(TagStandard::Hashtag(hashtag.as_ref().to_lowercase())) + Self::from_standardized(TagStandard::Hashtag(hashtag.as_ref().to_lowercase())) } /// Compose `["r", ""]` tag @@ -450,7 +397,7 @@ impl Tag { where T: Into, { - Self::from_standardized_without_cell(TagStandard::Reference(reference.into())) + Self::from_standardized(TagStandard::Reference(reference.into())) } /// Compose `["title", ""]` tag @@ -459,13 +406,13 @@ impl Tag { where T: Into<String>, { - Self::from_standardized_without_cell(TagStandard::Title(title.into())) + Self::from_standardized(TagStandard::Title(title.into())) } /// Compose image tag #[inline] pub fn image(url: Url, dimensions: Option<ImageDimensions>) -> Self { - Self::from_standardized_without_cell(TagStandard::Image(url, dimensions)) + Self::from_standardized(TagStandard::Image(url, dimensions)) } /// Compose `["description", "<description>"]` tag @@ -474,7 +421,7 @@ impl Tag { where T: Into<String>, { - Self::from_standardized_without_cell(TagStandard::Description(description.into())) + Self::from_standardized(TagStandard::Description(description.into())) } /// Protected event @@ -482,7 +429,7 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/70.md> #[inline] pub fn protected() -> Self { - Self::from_standardized_without_cell(TagStandard::Protected) + Self::from_standardized(TagStandard::Protected) } /// A short human-readable plaintext summary of what that event is about @@ -495,7 +442,7 @@ impl Tag { where T: Into<String>, { - Self::from_standardized_without_cell(TagStandard::Alt(summary.into())) + Self::from_standardized(TagStandard::Alt(summary.into())) } /// Compose custom tag @@ -511,14 +458,13 @@ impl Tag { buf.push(kind.to_string()); buf.extend(values.into_iter().map(|v| v.into())); - // NOT USE `Self::new`! - Self::new_with_empty_cell(buf) + Self::new(buf) } /// Check if is a standard event tag with `root` marker pub fn is_root(&self) -> bool { matches!( - self.as_standardized(), + self.standardized(), Some(TagStandard::Event { marker: Some(Marker::Root), .. @@ -529,7 +475,7 @@ impl Tag { /// Check if is a standard event tag with `reply` marker pub fn is_reply(&self) -> bool { matches!( - self.as_standardized(), + self.standardized(), Some(TagStandard::Event { marker: Some(Marker::Reply), .. @@ -542,7 +488,7 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/70.md> #[inline] pub fn is_protected(&self) -> bool { - matches!(self.as_standardized(), Some(TagStandard::Protected)) + matches!(self.standardized(), Some(TagStandard::Protected)) } } @@ -582,7 +528,7 @@ impl<'de> Deserialize<'de> for Tag { impl From<TagStandard> for Tag { fn from(standard: TagStandard) -> Self { - Self::from_standardized_without_cell(standard) + Self::from_standardized(standard) } } @@ -607,13 +553,13 @@ mod tests { fn test_tag_match_standardized() { let tag: Tag = Tag::parse(["d", "bravery"]).unwrap(); assert_eq!( - tag.as_standardized(), - Some(&TagStandard::Identifier(String::from("bravery"))) + tag.standardized(), + Some(TagStandard::Identifier(String::from("bravery"))) ); let tag: Tag = Tag::parse(["d", "test"]).unwrap(); assert_eq!( - tag.to_standardized(), + tag.standardized(), Some(TagStandard::Identifier(String::from("test"))) ); } diff --git a/crates/nostr/src/nips/nip17.rs b/crates/nostr/src/nips/nip17.rs index 5b260f0da..ae7394596 100644 --- a/crates/nostr/src/nips/nip17.rs +++ b/crates/nostr/src/nips/nip17.rs @@ -11,22 +11,9 @@ use crate::{Event, RelayUrl, TagStandard}; /// Extracts the relay list /// /// This function doesn't verify if the event kind is [`Kind::InboxRelays`](crate::Kind::InboxRelays)! -pub fn extract_relay_list(event: &Event) -> impl Iterator<Item = &RelayUrl> { +pub fn extract_relay_list(event: &Event) -> impl Iterator<Item = RelayUrl> + '_ { event.tags.iter().filter_map(|tag| { - if let Some(TagStandard::Relay(url)) = tag.as_standardized() { - Some(url) - } else { - None - } - }) -} - -/// Extracts the relay list -/// -/// This function doesn't verify if the event kind is [`Kind::InboxRelays`](crate::Kind::InboxRelays)! -pub fn extract_owned_relay_list(event: Event) -> impl Iterator<Item = RelayUrl> { - event.tags.into_iter().filter_map(|tag| { - if let Some(TagStandard::Relay(url)) = tag.to_standardized() { + if let Some(TagStandard::Relay(url)) = tag.standardized() { Some(url) } else { None diff --git a/crates/nostr/src/nips/nip22.rs b/crates/nostr/src/nips/nip22.rs index 64cd5d499..a9de0c7a0 100644 --- a/crates/nostr/src/nips/nip22.rs +++ b/crates/nostr/src/nips/nip22.rs @@ -128,7 +128,7 @@ impl<'a> CommentTarget<'a> { tags.reserve_exact( 1 + usize::from(pubkey_hint.is_some()) + usize::from(kind.is_some()), ); - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { + tags.push(Tag::from_standardized(TagStandard::Event { event_id: *id, relay_url: relay_hint.clone().map(|r| r.into_owned()), marker: None, @@ -137,18 +137,16 @@ impl<'a> CommentTarget<'a> { })); if let Some(pubkey) = pubkey_hint { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKey { - public_key: *pubkey, - relay_url: relay_hint.clone().map(|r| r.into_owned()), - alias: None, - uppercase: is_root, - }, - )); + tags.push(Tag::from_standardized(TagStandard::PublicKey { + public_key: *pubkey, + relay_url: relay_hint.clone().map(|r| r.into_owned()), + alias: None, + uppercase: is_root, + })); } if let Some(kind) = kind { - tags.push(Tag::from_standardized_without_cell(TagStandard::Kind { + tags.push(Tag::from_standardized(TagStandard::Kind { kind: *kind, uppercase: is_root, })); @@ -163,41 +161,33 @@ impl<'a> CommentTarget<'a> { let kind: Kind = address.kind; tags.reserve_exact(3); - tags.push(Tag::from_standardized_without_cell( - TagStandard::Coordinate { - coordinate: address.clone().into_owned(), - relay_url: relay_hint.clone().map(|r| r.into_owned()), - uppercase: is_root, - }, - )); - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKey { - public_key, - relay_url: relay_hint.clone().map(|r| r.into_owned()), - alias: None, - uppercase: is_root, - }, - )); - tags.push(Tag::from_standardized_without_cell(TagStandard::Kind { + tags.push(Tag::from_standardized(TagStandard::Coordinate { + coordinate: address.clone().into_owned(), + relay_url: relay_hint.clone().map(|r| r.into_owned()), + uppercase: is_root, + })); + tags.push(Tag::from_standardized(TagStandard::PublicKey { + public_key, + relay_url: relay_hint.clone().map(|r| r.into_owned()), + alias: None, + uppercase: is_root, + })); + tags.push(Tag::from_standardized(TagStandard::Kind { kind, uppercase: is_root, })); } Self::External { content, hint } => { tags.reserve_exact(2); - tags.push(Tag::from_standardized_without_cell( - TagStandard::ExternalContent { - content: ExternalContentId::clone(content), - hint: hint.clone().map(|r| r.into_owned()), - uppercase: is_root, - }, - )); - tags.push(Tag::from_standardized_without_cell( - TagStandard::Nip73Kind { - kind: content.kind(), - uppercase: is_root, - }, - )) + tags.push(Tag::from_standardized(TagStandard::ExternalContent { + content: ExternalContentId::clone(content), + hint: hint.clone().map(|r| r.into_owned()), + uppercase: is_root, + })); + tags.push(Tag::from_standardized(TagStandard::Nip73Kind { + kind: content.kind(), + uppercase: is_root, + })) } } @@ -208,7 +198,7 @@ impl<'a> CommentTarget<'a> { impl<'e> From<&'e Event> for CommentTarget<'_> { fn from(event: &'e Event) -> Self { if let Some(coordinate) = event.coordinate() { - CommentTarget::coordinate(Cow::Owned(coordinate.into_owned()), None) + CommentTarget::coordinate(Cow::Owned(coordinate), None) } else { CommentTarget::event(event.id, event.kind, Some(event.pubkey), None) } @@ -233,10 +223,10 @@ fn extract_data(event: &Event, is_root: bool) -> Option<CommentTarget<'_>> { // Try to extract event if let Some((event_id, relay_hint, public_key)) = extract_event(event, is_root) { return Some(CommentTarget::Event { - id: *event_id, - relay_hint: relay_hint.map(Cow::Borrowed), - pubkey_hint: public_key.copied(), - kind: extract_kind(event, is_root).copied(), + id: event_id, + relay_hint: relay_hint.map(Cow::Owned), + pubkey_hint: public_key, + kind: extract_kind(event, is_root), }); } @@ -244,7 +234,7 @@ fn extract_data(event: &Event, is_root: bool) -> Option<CommentTarget<'_>> { if let Some((address, relay_hint)) = extract_coordinate(event, is_root) { // Extract kind // TODO: for now we allow optional `k`/`K` tag, but according to NIP-22, it should be mandatory. - let kind: Option<Kind> = extract_kind(event, is_root).copied(); + let kind: Option<Kind> = extract_kind(event, is_root); // Check if matches the address kind if let Some(kind) = kind { @@ -254,15 +244,15 @@ fn extract_data(event: &Event, is_root: bool) -> Option<CommentTarget<'_>> { } return Some(CommentTarget::Coordinate { - address: Cow::Borrowed(address), - relay_hint: relay_hint.map(Cow::Borrowed), + address: Cow::Owned(address), + relay_hint: relay_hint.map(Cow::Owned), }); } if let Some((content, hint)) = extract_external(event, is_root) { return Some(CommentTarget::External { - content: Cow::Borrowed(content), - hint: hint.map(Cow::Borrowed), + content: Cow::Owned(content), + hint: hint.map(Cow::Owned), }); } @@ -282,12 +272,12 @@ fn check_return<T>(val: T, is_root: bool, uppercase: bool) -> Option<T> { /// # Example: /// * is_root = true -> returns first `K` tag /// * is_root = false -> returns first `k` tag -fn extract_kind(event: &Event, is_root: bool) -> Option<&Kind> { +fn extract_kind(event: &Event, is_root: bool) -> Option<Kind> { event .tags .filter_standardized(TagKind::single_letter(Alphabet::K, is_root)) .find_map(|tag| match tag { - TagStandard::Kind { kind, uppercase } => check_return(kind, is_root, *uppercase), + TagStandard::Kind { kind, uppercase } => check_return(kind, is_root, uppercase), _ => None, }) } @@ -300,7 +290,7 @@ fn extract_kind(event: &Event, is_root: bool) -> Option<&Kind> { fn extract_event( event: &Event, is_root: bool, -) -> Option<(&EventId, Option<&RelayUrl>, Option<&PublicKey>)> { +) -> Option<(EventId, Option<RelayUrl>, Option<PublicKey>)> { event .tags .filter_standardized(TagKind::single_letter(Alphabet::E, is_root)) @@ -311,11 +301,7 @@ fn extract_event( public_key, uppercase, .. - } => check_return( - (event_id, relay_url.as_ref(), public_key.as_ref()), - is_root, - *uppercase, - ), + } => check_return((event_id, relay_url, public_key), is_root, uppercase), _ => None, }) } @@ -325,7 +311,7 @@ fn extract_event( /// # Example: /// * is_root = true -> returns first `A` tag /// * is_root = false -> returns first `a` tag -fn extract_coordinate(event: &Event, is_root: bool) -> Option<(&Coordinate, Option<&RelayUrl>)> { +fn extract_coordinate(event: &Event, is_root: bool) -> Option<(Coordinate, Option<RelayUrl>)> { event .tags .filter_standardized(TagKind::single_letter(Alphabet::A, is_root)) @@ -335,7 +321,7 @@ fn extract_coordinate(event: &Event, is_root: bool) -> Option<(&Coordinate, Opti relay_url, uppercase, .. - } => check_return((coordinate, relay_url.as_ref()), is_root, *uppercase), + } => check_return((coordinate, relay_url), is_root, uppercase), _ => None, }) } @@ -345,7 +331,7 @@ fn extract_coordinate(event: &Event, is_root: bool) -> Option<(&Coordinate, Opti /// # Example: /// * is_root = true -> returns first `I` tag /// * is_root = false -> returns first `i` tag -fn extract_external(event: &Event, is_root: bool) -> Option<(&ExternalContentId, Option<&Url>)> { +fn extract_external(event: &Event, is_root: bool) -> Option<(ExternalContentId, Option<Url>)> { event .tags .filter_standardized(TagKind::single_letter(Alphabet::I, is_root)) @@ -354,7 +340,7 @@ fn extract_external(event: &Event, is_root: bool) -> Option<(&ExternalContentId, content, hint, uppercase, - } => check_return((content, hint.as_ref()), is_root, *uppercase), + } => check_return((content, hint), is_root, uppercase), _ => None, }) } @@ -365,29 +351,30 @@ mod tests { use crate::prelude::*; fn check_kind(tags: &[Tag], kind: Kind, uppercase: bool) { + assert!(tags.contains(&Tag::from_standardized(TagStandard::Kind { + kind, + uppercase + }))); + } + + fn check_nip73_kind(tags: &[Tag], kind: Nip73Kind, uppercase: bool) { assert!( - tags.contains(&Tag::from_standardized_without_cell(TagStandard::Kind { + tags.contains(&Tag::from_standardized(TagStandard::Nip73Kind { kind, uppercase })) ); } - fn check_nip73_kind(tags: &[Tag], kind: Nip73Kind, uppercase: bool) { - assert!(tags.contains(&Tag::from_standardized_without_cell( - TagStandard::Nip73Kind { kind, uppercase } - ))); - } - fn check_pubkey(tags: &[Tag], public_key: PublicKey, uppercase: bool) { - assert!(tags.contains(&Tag::from_standardized_without_cell( - TagStandard::PublicKey { + assert!( + tags.contains(&Tag::from_standardized(TagStandard::PublicKey { public_key, relay_url: None, alias: None, uppercase - } - ))); + })) + ); } #[test] @@ -407,7 +394,7 @@ mod tests { // Root let root_vec = comment_target.as_vec(true); assert!( - root_vec.contains(&Tag::from_standardized_without_cell(TagStandard::Event { + root_vec.contains(&Tag::from_standardized(TagStandard::Event { event_id, relay_url: None, marker: None, @@ -421,7 +408,7 @@ mod tests { // Parent let parent_vec = comment_target.as_vec(false); assert!( - parent_vec.contains(&Tag::from_standardized_without_cell(TagStandard::Event { + parent_vec.contains(&Tag::from_standardized(TagStandard::Event { event_id, relay_url: None, marker: None, @@ -443,13 +430,13 @@ mod tests { // Root let root_vec = comment_target.as_vec(true); - assert!(root_vec.contains(&Tag::from_standardized_without_cell( - TagStandard::Coordinate { + assert!( + root_vec.contains(&Tag::from_standardized(TagStandard::Coordinate { coordinate: coordinate.clone(), relay_url: None, uppercase: true - } - ))); + })) + ); check_pubkey(&root_vec, keys.public_key(), true); check_kind(&root_vec, kind, true); @@ -469,24 +456,24 @@ mod tests { // Root let root_vec = comment_target.as_vec(true); - assert!(root_vec.contains(&Tag::from_standardized_without_cell( - TagStandard::ExternalContent { + assert!( + root_vec.contains(&Tag::from_standardized(TagStandard::ExternalContent { content: external_content.clone(), hint: None, uppercase: true - } - ))); + })) + ); check_nip73_kind(&root_vec, kind.clone(), true); // Parent let parent_vec = comment_target.as_vec(false); - assert!(parent_vec.contains(&Tag::from_standardized_without_cell( - TagStandard::ExternalContent { + assert!( + parent_vec.contains(&Tag::from_standardized(TagStandard::ExternalContent { content: external_content.clone(), hint: None, uppercase: false - } - ))); + })) + ); check_nip73_kind(&parent_vec, kind, false); } } diff --git a/crates/nostr/src/nips/nip25.rs b/crates/nostr/src/nips/nip25.rs index 8ce317338..ca3c3a6ef 100644 --- a/crates/nostr/src/nips/nip25.rs +++ b/crates/nostr/src/nips/nip25.rs @@ -30,7 +30,7 @@ impl ReactionTarget { Self { event_id: event.id, public_key: event.pubkey, - coordinate: event.coordinate().map(|c| c.into_owned()), + coordinate: event.coordinate(), kind: Some(event.kind), relay_hint, } @@ -43,7 +43,7 @@ impl ReactionTarget { // Serialization order: keep the `e` and `a` tags together, followed by the `p` and other tags. - tags.push(Tag::from_standardized_without_cell(TagStandard::Event { + tags.push(Tag::from_standardized(TagStandard::Event { event_id: self.event_id, relay_url: self.relay_hint.clone(), public_key: Some(self.public_key), @@ -55,17 +55,15 @@ impl ReactionTarget { tags.push(Tag::coordinate(coordinate, self.relay_hint.clone())); } - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKey { - public_key: self.public_key, - relay_url: self.relay_hint, - alias: None, - uppercase: false, - }, - )); + tags.push(Tag::from_standardized(TagStandard::PublicKey { + public_key: self.public_key, + relay_url: self.relay_hint, + alias: None, + uppercase: false, + })); if let Some(kind) = self.kind { - tags.push(Tag::from_standardized_without_cell(TagStandard::Kind { + tags.push(Tag::from_standardized(TagStandard::Kind { kind, uppercase: false, })); @@ -80,7 +78,7 @@ impl From<&Event> for ReactionTarget { Self { event_id: event.id, public_key: event.pubkey, - coordinate: event.coordinate().map(|c| c.into_owned()), + coordinate: event.coordinate(), kind: Some(event.kind), relay_hint: None, } diff --git a/crates/nostr/src/nips/nip34.rs b/crates/nostr/src/nips/nip34.rs index 248d24923..5d2a719a5 100644 --- a/crates/nostr/src/nips/nip34.rs +++ b/crates/nostr/src/nips/nip34.rs @@ -66,49 +66,43 @@ impl GitRepositoryAnnouncement { // Add name if let Some(name) = self.name { - tags.push(Tag::from_standardized_without_cell(TagStandard::Name(name))); + tags.push(Tag::from_standardized(TagStandard::Name(name))); } // Add description if let Some(description) = self.description { - tags.push(Tag::from_standardized_without_cell( - TagStandard::Description(description), - )); + tags.push(Tag::from_standardized(TagStandard::Description( + description, + ))); } // Add web if !self.web.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Web( - self.web, - ))); + tags.push(Tag::from_standardized(TagStandard::Web(self.web))); } // Add clone if !self.clone.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::GitClone( - self.clone, - ))); + tags.push(Tag::from_standardized(TagStandard::GitClone(self.clone))); } // Add relays if !self.relays.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Relays( - self.relays, - ))); + tags.push(Tag::from_standardized(TagStandard::Relays(self.relays))); } // Add EUC if let Some(commit) = self.euc { - tags.push(Tag::from_standardized_without_cell( + tags.push(Tag::from_standardized( TagStandard::GitEarliestUniqueCommitId(commit), )); } // Add maintainers if !self.maintainers.is_empty() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::GitMaintainers(self.maintainers), - )); + tags.push(Tag::from_standardized(TagStandard::GitMaintainers( + self.maintainers, + ))); } // Build @@ -155,9 +149,7 @@ impl GitIssue { // Add subject if let Some(subject) = self.subject { - tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( - subject, - ))); + tags.push(Tag::from_standardized(TagStandard::Subject(subject))); } // Add labels @@ -286,9 +278,7 @@ impl GitPatch { } => { tags.reserve_exact(5); tags.push(Tag::reference(commit.to_string())); - tags.push(Tag::from_standardized_without_cell(TagStandard::GitCommit( - commit, - ))); + tags.push(Tag::from_standardized(TagStandard::GitCommit(commit))); tags.push(Tag::custom( TagKind::Custom(Cow::Borrowed("parent-commit")), vec![parent_commit.to_string()], @@ -367,9 +357,7 @@ impl GitPullRequest { // Add subject if let Some(subject) = self.subject { - tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( - subject, - ))); + tags.push(Tag::from_standardized(TagStandard::Subject(subject))); } // Add labels @@ -383,16 +371,14 @@ impl GitPullRequest { // Add clone URLs if !self.clone.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::GitClone( - self.clone, - ))); + tags.push(Tag::from_standardized(TagStandard::GitClone(self.clone))); } // Add branch name if let Some(branch_name) = self.branch_name { - tags.push(Tag::from_standardized_without_cell( - TagStandard::GitBranchName(branch_name), - )); + tags.push(Tag::from_standardized(TagStandard::GitBranchName( + branch_name, + ))); } // Add root patch event (if this is a revision) @@ -402,9 +388,9 @@ impl GitPullRequest { // Add merge base if let Some(merge_base) = self.merge_base { - tags.push(Tag::from_standardized_without_cell( - TagStandard::GitMergeBase(merge_base), - )); + tags.push(Tag::from_standardized(TagStandard::GitMergeBase( + merge_base, + ))); } // Build @@ -471,16 +457,14 @@ impl GitPullRequestUpdate { // Add clone URLs if !self.clone.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::GitClone( - self.clone, - ))); + tags.push(Tag::from_standardized(TagStandard::GitClone(self.clone))); } // Add merge base if let Some(merge_base) = self.merge_base { - tags.push(Tag::from_standardized_without_cell( - TagStandard::GitMergeBase(merge_base), - )); + tags.push(Tag::from_standardized(TagStandard::GitMergeBase( + merge_base, + ))); } // Build diff --git a/crates/nostr/src/nips/nip42.rs b/crates/nostr/src/nips/nip42.rs index 675b3b17d..c748a23a6 100644 --- a/crates/nostr/src/nips/nip42.rs +++ b/crates/nostr/src/nips/nip42.rs @@ -25,7 +25,7 @@ pub fn is_valid_auth_event(event: &Event, relay_url: &RelayUrl, challenge: &str) // Check if it has "relay" tag match event.tags.find_standardized(TagKind::Relay) { Some(TagStandard::Relay(url)) => { - if url != relay_url { + if &url != relay_url { return false; } } diff --git a/crates/nostr/src/nips/nip51.rs b/crates/nostr/src/nips/nip51.rs index dad8231bf..7dad19d27 100644 --- a/crates/nostr/src/nips/nip51.rs +++ b/crates/nostr/src/nips/nip51.rs @@ -126,9 +126,11 @@ impl From<Emojis> for Vec<Tag> { fn from(Emojis { emojis, coordinate }: Emojis) -> Self { let mut tags = Vec::with_capacity(emojis.len() + coordinate.len()); - tags.extend(emojis.into_iter().map(|(s, url)| { - Tag::from_standardized_without_cell(TagStandard::Emoji { shortcode: s, url }) - })); + tags.extend( + emojis + .into_iter() + .map(|(s, url)| Tag::from_standardized(TagStandard::Emoji { shortcode: s, url })), + ); tags.extend(coordinate.into_iter().map(Tag::from)); tags diff --git a/crates/nostr/src/nips/nip53.rs b/crates/nostr/src/nips/nip53.rs index dbecfe59f..ec7716b9d 100644 --- a/crates/nostr/src/nips/nip53.rs +++ b/crates/nostr/src/nips/nip53.rs @@ -224,27 +224,19 @@ impl From<LiveEvent> for Vec<Tag> { tags.push(Tag::identifier(id)); if let Some(title) = title { - tags.push(Tag::from_standardized_without_cell(TagStandard::Title( - title, - ))); + tags.push(Tag::from_standardized(TagStandard::Title(title))); } if let Some(summary) = summary { - tags.push(Tag::from_standardized_without_cell(TagStandard::Summary( - summary, - ))); + tags.push(Tag::from_standardized(TagStandard::Summary(summary))); } if let Some(streaming) = streaming { - tags.push(Tag::from_standardized_without_cell(TagStandard::Streaming( - streaming, - ))); + tags.push(Tag::from_standardized(TagStandard::Streaming(streaming))); } if let Some(status) = status { - tags.push(Tag::from_standardized_without_cell( - TagStandard::LiveEventStatus(status), - )); + tags.push(Tag::from_standardized(TagStandard::LiveEventStatus(status))); } if let Some(LiveEventHost { @@ -253,82 +245,66 @@ impl From<LiveEvent> for Vec<Tag> { proof, }) = host { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker: LiveEventMarker::Host, - proof, - }, - )); + tags.push(Tag::from_standardized(TagStandard::PublicKeyLiveEvent { + public_key, + relay_url, + marker: LiveEventMarker::Host, + proof, + })); } for (public_key, relay_url) in speakers.into_iter() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker: LiveEventMarker::Speaker, - proof: None, - }, - )); + tags.push(Tag::from_standardized(TagStandard::PublicKeyLiveEvent { + public_key, + relay_url, + marker: LiveEventMarker::Speaker, + proof: None, + })); } for (public_key, relay_url) in participants.into_iter() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker: LiveEventMarker::Participant, - proof: None, - }, - )); + tags.push(Tag::from_standardized(TagStandard::PublicKeyLiveEvent { + public_key, + relay_url, + marker: LiveEventMarker::Participant, + proof: None, + })); } if let Some((image, dim)) = image { - tags.push(Tag::from_standardized_without_cell(TagStandard::Image( - image, dim, - ))); + tags.push(Tag::from_standardized(TagStandard::Image(image, dim))); } for hashtag in hashtags.into_iter() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Hashtag( - hashtag, - ))); + tags.push(Tag::from_standardized(TagStandard::Hashtag(hashtag))); } if let Some(recording) = recording { - tags.push(Tag::from_standardized_without_cell(TagStandard::Recording( - recording, - ))); + tags.push(Tag::from_standardized(TagStandard::Recording(recording))); } if let Some(starts) = starts { - tags.push(Tag::from_standardized_without_cell(TagStandard::Starts( - starts, - ))); + tags.push(Tag::from_standardized(TagStandard::Starts(starts))); } if let Some(ends) = ends { - tags.push(Tag::from_standardized_without_cell(TagStandard::Ends(ends))); + tags.push(Tag::from_standardized(TagStandard::Ends(ends))); } if let Some(current_participants) = current_participants { - tags.push(Tag::from_standardized_without_cell( - TagStandard::CurrentParticipants(current_participants), - )); + tags.push(Tag::from_standardized(TagStandard::CurrentParticipants( + current_participants, + ))); } if let Some(total_participants) = total_participants { - tags.push(Tag::from_standardized_without_cell( - TagStandard::TotalParticipants(total_participants), - )); + tags.push(Tag::from_standardized(TagStandard::TotalParticipants( + total_participants, + ))); } if !relays.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Relays( - relays, - ))); + tags.push(Tag::from_standardized(TagStandard::Relays(relays))); } tags @@ -349,7 +325,7 @@ impl TryFrom<Vec<Tag>> for LiveEvent { let mut live_event = LiveEvent::new(id); for tag in tags.into_iter() { - let Some(tag) = tag.to_standardized() else { + let Some(tag) = tag.standardized() else { continue; }; diff --git a/crates/nostr/src/nips/nip57.rs b/crates/nostr/src/nips/nip57.rs index 3bff32f63..e05923ee1 100644 --- a/crates/nostr/src/nips/nip57.rs +++ b/crates/nostr/src/nips/nip57.rs @@ -226,9 +226,7 @@ impl From<ZapRequestData> for Vec<Tag> { let mut tags: Vec<Tag> = vec![Tag::public_key(public_key)]; if !relays.is_empty() { - tags.push(Tag::from_standardized_without_cell(TagStandard::Relays( - relays, - ))); + tags.push(Tag::from_standardized(TagStandard::Relays(relays))); } if let Some(event_id) = event_id { @@ -240,16 +238,14 @@ impl From<ZapRequestData> for Vec<Tag> { } if let Some(amount) = amount { - tags.push(Tag::from_standardized_without_cell(TagStandard::Amount { + tags.push(Tag::from_standardized(TagStandard::Amount { millisats: amount, bolt11: None, })); } if let Some(lnurl) = lnurl { - tags.push(Tag::from_standardized_without_cell(TagStandard::Lnurl( - lnurl, - ))); + tags.push(Tag::from_standardized(TagStandard::Lnurl(lnurl))); } tags @@ -262,9 +258,7 @@ pub fn anonymous_zap_request(data: ZapRequestData) -> Result<Event, Error> { let keys = Keys::generate(); let message: String = data.message.clone(); let mut tags: Vec<Tag> = data.into(); - tags.push(Tag::from_standardized_without_cell(TagStandard::Anon { - msg: None, - })); + tags.push(Tag::from_standardized(TagStandard::Anon { msg: None })); Ok(EventBuilder::new(Kind::ZapRequest, message) .tags(tags) .sign_with_keys(&keys)?) @@ -308,9 +302,7 @@ where // Compose event let mut tags: Vec<Tag> = data.into(); - tags.push(Tag::from_standardized_without_cell(TagStandard::Anon { - msg: Some(msg), - })); + tags.push(Tag::from_standardized(TagStandard::Anon { msg: Some(msg) })); let private_zap_keys: Keys = Keys::new_with_ctx(secp, secret_key); Ok(EventBuilder::new(Kind::ZapRequest, "") .tags(tags) @@ -360,10 +352,10 @@ where Ok(format!("{encrypted_bech32_msg}_{iv_bech32}")) } -fn extract_anon_tag_message(event: &Event) -> Result<&String, Error> { +fn extract_anon_tag_message(event: &Event) -> Result<String, Error> { for tag in event.tags.iter() { - if let Some(TagStandard::Anon { msg }) = tag.as_standardized() { - return msg.as_ref().ok_or(Error::InvalidPrivateZapMessage); + if let Some(TagStandard::Anon { msg }) = tag.standardized() { + return msg.ok_or(Error::InvalidPrivateZapMessage); } } Err(Error::PrivateZapMessageNotFound) @@ -395,7 +387,7 @@ pub fn decrypt_received_private_zap_message( } fn decrypt_private_zap_message(key: [u8; 32], private_zap_event: &Event) -> Result<Event, Error> { - let msg: &String = extract_anon_tag_message(private_zap_event)?; + let msg: String = extract_anon_tag_message(private_zap_event)?; let mut splitted = msg.split('_'); let msg: &str = splitted.next().ok_or(Error::InvalidPrivateZapMessage)?; diff --git a/crates/nostr/src/nips/nip58.rs b/crates/nostr/src/nips/nip58.rs index fbaf0fd76..632abb91d 100644 --- a/crates/nostr/src/nips/nip58.rs +++ b/crates/nostr/src/nips/nip58.rs @@ -51,11 +51,11 @@ pub(crate) fn filter_for_kind(events: Vec<Event>, kind_needed: &Kind) -> Vec<Eve } /// Helper function to extract the awarded public key from an array of PubKey tags -pub(crate) fn extract_awarded_public_key<'a>( - tags: &'a [Tag], - awarded_public_key: &PublicKey, -) -> Option<(&'a PublicKey, &'a Option<RelayUrl>)> { - tags.iter().find_map(|t| match t.as_standardized() { +pub(crate) fn extract_awarded_public_key( + tags: &[Tag], + awarded_public_key: PublicKey, +) -> Option<(PublicKey, Option<RelayUrl>)> { + tags.iter().find_map(|t| match t.standardized() { Some(TagStandard::PublicKey { public_key, relay_url, diff --git a/crates/nostr/src/nips/nip60.rs b/crates/nostr/src/nips/nip60.rs index 4f5155c1a..f8bdc13fb 100644 --- a/crates/nostr/src/nips/nip60.rs +++ b/crates/nostr/src/nips/nip60.rs @@ -549,7 +549,7 @@ impl QuoteEvent { .map_err(|_| Error::InvalidMintUrl)?; // Extract NIP-40 expiration from tags if present - let expiration: Option<Timestamp> = event.tags.expiration().copied(); + let expiration: Option<Timestamp> = event.tags.expiration(); Ok(Self { quote_id, diff --git a/crates/nostr/src/nips/nip65.rs b/crates/nostr/src/nips/nip65.rs index 125bff210..e74db23c8 100644 --- a/crates/nostr/src/nips/nip65.rs +++ b/crates/nostr/src/nips/nip65.rs @@ -81,30 +81,12 @@ impl FromStr for RelayMetadata { #[inline] pub fn extract_relay_list( event: &Event, -) -> impl Iterator<Item = (&RelayUrl, &Option<RelayMetadata>)> { +) -> impl Iterator<Item = (RelayUrl, Option<RelayMetadata>)> + '_ { event.tags.iter().filter_map(|tag| { if let Some(TagStandard::RelayMetadata { relay_url, metadata, - }) = tag.as_standardized() - { - Some((relay_url, metadata)) - } else { - None - } - }) -} - -/// Extracts the relay info (url, optional read/write flag) from the event -#[inline] -pub fn extract_owned_relay_list( - event: Event, -) -> impl Iterator<Item = (RelayUrl, Option<RelayMetadata>)> { - event.tags.into_iter().filter_map(|tag| { - if let Some(TagStandard::RelayMetadata { - relay_url, - metadata, - }) = tag.to_standardized() + }) = tag.standardized() { Some((relay_url, metadata)) } else { diff --git a/crates/nostr/src/nips/nip88.rs b/crates/nostr/src/nips/nip88.rs index 52d58071c..66d09957e 100644 --- a/crates/nostr/src/nips/nip88.rs +++ b/crates/nostr/src/nips/nip88.rs @@ -103,7 +103,7 @@ impl Poll { pub fn from_event(event: &Event) -> Result<Self, Error> { // Search poll type let poll_type: PollType = match event.tags.find_standardized(TagKind::PollType) { - Some(TagStandard::PollType(poll_type)) => *poll_type, + Some(TagStandard::PollType(poll_type)) => poll_type, // Found an unexpected tag. Some(..) => return Err(Error::UnexpectedTag), // If no valid "polltype" tag is found, the "singlechoice" will be the default. @@ -132,7 +132,7 @@ impl Poll { // Search ends timestamp let ends_at: Option<Timestamp> = match event.tags.find_standardized(ENDS_AT_TAG_KIND) { - Some(TagStandard::PollEndsAt(timestamp)) => Some(*timestamp), + Some(TagStandard::PollEndsAt(timestamp)) => Some(timestamp), Some(..) => return Err(Error::UnexpectedTag), None => None, }; @@ -150,14 +150,10 @@ impl Poll { pub(crate) fn to_event_builder(self) -> EventBuilder { let mut tags: Vec<Tag> = Vec::with_capacity(1 + self.options.len() + self.relays.len()); - tags.push(Tag::from_standardized_without_cell(TagStandard::PollType( - self.r#type, - ))); + tags.push(Tag::from_standardized(TagStandard::PollType(self.r#type))); for option in self.options.into_iter() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PollOption(option), - )); + tags.push(Tag::from_standardized(TagStandard::PollOption(option))); } for url in self.relays.into_iter() { @@ -198,7 +194,7 @@ impl PollResponse { Self::SingleChoice { poll_id, response } => { vec![ Tag::event(poll_id), - Tag::from_standardized_without_cell(TagStandard::PollResponse(response)), + Tag::from_standardized(TagStandard::PollResponse(response)), ] } Self::MultipleChoice { poll_id, responses } => { @@ -207,9 +203,7 @@ impl PollResponse { tags.push(Tag::event(poll_id)); for response in responses.into_iter() { - tags.push(Tag::from_standardized_without_cell( - TagStandard::PollResponse(response), - )); + tags.push(Tag::from_standardized(TagStandard::PollResponse(response))); } tags diff --git a/crates/nostr/src/nips/nip94.rs b/crates/nostr/src/nips/nip94.rs index 7387862ba..836e512c5 100644 --- a/crates/nostr/src/nips/nip94.rs +++ b/crates/nostr/src/nips/nip94.rs @@ -141,38 +141,28 @@ impl From<FileMetadata> for Vec<Tag> { let mut tags: Vec<Tag> = Vec::with_capacity(3); - tags.push(Tag::from_standardized_without_cell(TagStandard::Url(url))); - tags.push(Tag::from_standardized_without_cell(TagStandard::MimeType( - mime_type, - ))); - tags.push(Tag::from_standardized_without_cell(TagStandard::Sha256( - hash, - ))); + tags.push(Tag::from_standardized(TagStandard::Url(url))); + tags.push(Tag::from_standardized(TagStandard::MimeType(mime_type))); + tags.push(Tag::from_standardized(TagStandard::Sha256(hash))); if let Some((key, iv)) = aes_256_gcm { - tags.push(Tag::from_standardized_without_cell( - TagStandard::Aes256Gcm { key, iv }, - )); + tags.push(Tag::from_standardized(TagStandard::Aes256Gcm { key, iv })); } if let Some(size) = size { - tags.push(Tag::from_standardized_without_cell(TagStandard::Size(size))); + tags.push(Tag::from_standardized(TagStandard::Size(size))); } if let Some(dim) = dim { - tags.push(Tag::from_standardized_without_cell(TagStandard::Dim(dim))); + tags.push(Tag::from_standardized(TagStandard::Dim(dim))); } if let Some(magnet) = magnet { - tags.push(Tag::from_standardized_without_cell(TagStandard::Magnet( - magnet, - ))); + tags.push(Tag::from_standardized(TagStandard::Magnet(magnet))); } if let Some(blurhash) = blurhash { - tags.push(Tag::from_standardized_without_cell(TagStandard::Blurhash( - blurhash, - ))); + tags.push(Tag::from_standardized(TagStandard::Blurhash(blurhash))); } tags @@ -186,7 +176,7 @@ impl TryFrom<Vec<Tag>> for FileMetadata { let url = match value .iter() .find(|t| t.kind() == TagKind::Url) - .map(|t| t.as_standardized()) + .map(|t| t.standardized()) { Some(Some(TagStandard::Url(url))) => Ok(url), _ => Err(Self::Error::MissingUrl), @@ -195,10 +185,10 @@ impl TryFrom<Vec<Tag>> for FileMetadata { let mime = match value .iter() .find(|t| { - let t = t.as_standardized(); + let t = t.standardized(); matches!(t, Some(TagStandard::MimeType(..))) }) - .map(|t| t.as_standardized()) + .map(|t| t.standardized()) { Some(Some(TagStandard::MimeType(mime))) => Ok(mime), _ => Err(Self::Error::MissingMimeType), @@ -207,19 +197,19 @@ impl TryFrom<Vec<Tag>> for FileMetadata { let sha256 = match value .iter() .find(|t| { - let t = t.as_standardized(); + let t = t.standardized(); matches!(t, Some(TagStandard::Sha256(..))) }) - .map(|t| t.as_standardized()) + .map(|t| t.standardized()) { Some(Some(TagStandard::Sha256(sha256))) => Ok(sha256), _ => Err(Self::Error::MissingSha), }?; - let mut metadata = FileMetadata::new(url.clone(), mime, *sha256); + let mut metadata = FileMetadata::new(url.clone(), mime, sha256); if let Some(TagStandard::Aes256Gcm { key, iv }) = value.iter().find_map(|t| { - let t = t.as_standardized(); + let t = t.standardized(); if matches!(t, Some(TagStandard::Aes256Gcm { .. })) { t } else { @@ -230,29 +220,29 @@ impl TryFrom<Vec<Tag>> for FileMetadata { } if let Some(TagStandard::Size(size)) = value.iter().find_map(|t| { - let t = t.as_standardized(); + let t = t.standardized(); if matches!(t, Some(TagStandard::Size { .. })) { t } else { None } }) { - metadata = metadata.size(*size); + metadata = metadata.size(size); } if let Some(TagStandard::Dim(dim)) = value.iter().find_map(|t| { - let t = t.as_standardized(); + let t = t.standardized(); if matches!(t, Some(TagStandard::Dim { .. })) { t } else { None } }) { - metadata = metadata.dimensions(*dim); + metadata = metadata.dimensions(dim); } if let Some(TagStandard::Magnet(magnet)) = value.iter().find_map(|t| { - let t = t.as_standardized(); + let t = t.standardized(); if matches!(t, Some(TagStandard::Magnet { .. })) { t } else { @@ -263,7 +253,7 @@ impl TryFrom<Vec<Tag>> for FileMetadata { } if let Some(TagStandard::Blurhash(bh)) = value.iter().find_map(|t| { - let t = t.as_standardized(); + let t = t.standardized(); if matches!(t, Some(TagStandard::Blurhash { .. })) { t } else { @@ -296,10 +286,10 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Dim(dim)), - Tag::from_standardized_without_cell(TagStandard::Sha256(hash)), - Tag::from_standardized_without_cell(TagStandard::Url(url.clone())), - Tag::from_standardized_without_cell(TagStandard::MimeType(String::from("image/jpeg"))), + Tag::from_standardized(TagStandard::Dim(dim)), + Tag::from_standardized(TagStandard::Sha256(hash)), + Tag::from_standardized(TagStandard::Url(url.clone())), + Tag::from_standardized(TagStandard::MimeType(String::from("image/jpeg"))), ]; let got = FileMetadata::try_from(tags).unwrap(); let expected = FileMetadata::new(url, "image/jpeg", hash).dimensions(dim); @@ -315,9 +305,9 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Dim(dim)), - Tag::from_standardized_without_cell(TagStandard::Sha256(hash)), - Tag::from_standardized_without_cell(TagStandard::MimeType(String::from("image/jpeg"))), + Tag::from_standardized(TagStandard::Dim(dim)), + Tag::from_standardized(TagStandard::Sha256(hash)), + Tag::from_standardized(TagStandard::MimeType(String::from("image/jpeg"))), ]; let got = FileMetadata::try_from(tags).unwrap_err(); @@ -333,9 +323,9 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Dim(dim)), - Tag::from_standardized_without_cell(TagStandard::Sha256(hash)), - Tag::from_standardized_without_cell(TagStandard::Url(url.clone())), + Tag::from_standardized(TagStandard::Dim(dim)), + Tag::from_standardized(TagStandard::Sha256(hash)), + Tag::from_standardized(TagStandard::Url(url.clone())), ]; let got = FileMetadata::try_from(tags).unwrap_err(); @@ -350,9 +340,9 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized_without_cell(TagStandard::Dim(dim)), - Tag::from_standardized_without_cell(TagStandard::Url(url.clone())), - Tag::from_standardized_without_cell(TagStandard::MimeType(String::from("image/jpeg"))), + Tag::from_standardized(TagStandard::Dim(dim)), + Tag::from_standardized(TagStandard::Url(url.clone())), + Tag::from_standardized(TagStandard::MimeType(String::from("image/jpeg"))), ]; let got = FileMetadata::try_from(tags).unwrap_err(); diff --git a/crates/nostr/src/nips/nip98.rs b/crates/nostr/src/nips/nip98.rs index e8b44d0fb..69a26cf6c 100644 --- a/crates/nostr/src/nips/nip98.rs +++ b/crates/nostr/src/nips/nip98.rs @@ -272,13 +272,11 @@ impl From<HttpData> for Vec<Tag> { } = data; let mut tags: Vec<Tag> = vec![ - Tag::from_standardized_without_cell(TagStandard::AbsoluteURL(url)), - Tag::from_standardized_without_cell(TagStandard::Method(method)), + Tag::from_standardized(TagStandard::AbsoluteURL(url)), + Tag::from_standardized(TagStandard::Method(method)), ]; if let Some(payload) = payload { - tags.push(Tag::from_standardized_without_cell(TagStandard::Payload( - payload, - ))); + tags.push(Tag::from_standardized(TagStandard::Payload(payload))); } tags @@ -291,27 +289,22 @@ impl TryFrom<Vec<Tag>> for HttpData { fn try_from(value: Vec<Tag>) -> Result<Self, Self::Error> { let url = value .iter() - .find_map(|t| match t.as_standardized() { + .find_map(|t| match t.standardized() { Some(TagStandard::AbsoluteURL(u)) => Some(u), _ => None, }) - .cloned() .ok_or(Error::MissingTag(RequiredTags::AbsoluteURL))?; let method = value .iter() - .find_map(|t| match t.as_standardized() { + .find_map(|t| match t.standardized() { Some(TagStandard::Method(m)) => Some(m), _ => None, }) - .cloned() .ok_or(Error::MissingTag(RequiredTags::Method))?; - let payload = value - .iter() - .find_map(|t| match t.as_standardized() { - Some(TagStandard::Payload(p)) => Some(p), - _ => None, - }) - .cloned(); + let payload = value.iter().find_map(|t| match t.standardized() { + Some(TagStandard::Payload(p)) => Some(p), + _ => None, + }); Ok(Self { url, @@ -365,7 +358,7 @@ pub fn verify_auth_header( return Err(Error::WrongAuthHeaderKind); } - let authorized_url: &Url = event + let authorized_url: Url = event .tags .find_standardized(TagKind::u()) .and_then(|tag| match tag { @@ -374,7 +367,7 @@ pub fn verify_auth_header( }) .ok_or(Error::MissingTag(RequiredTags::AbsoluteURL))?; - let authorized_method: &HttpMethod = event + let authorized_method: HttpMethod = event .tags .find_standardized(TagKind::Method) .and_then(|tag| match tag { @@ -383,10 +376,10 @@ pub fn verify_auth_header( }) .ok_or(Error::MissingTag(RequiredTags::Method))?; - if authorized_url != url || authorized_method != &method { + if &authorized_url != url || authorized_method != method { return Err(Error::AuthorizationNotMatchRequest { authorized_url: Box::new(authorized_url.clone()), - authorized_method: *authorized_method, + authorized_method, request_url: Box::new(url.clone()), request_method: method, }); @@ -404,7 +397,7 @@ pub fn verify_auth_header( if let Some(body_data) = body { // Get payload hash - let payload: &Sha256Hash = match event.tags.find_standardized(TagKind::Payload) { + let payload: Sha256Hash = match event.tags.find_standardized(TagKind::Payload) { Some(TagStandard::Payload(p)) => p, _ => return Err(Error::MissingTag(RequiredTags::Payload)), }; @@ -413,7 +406,7 @@ pub fn verify_auth_header( let body_hash: Sha256Hash = Sha256Hash::hash(body_data); // Check if payload and body hash matches - if payload != &body_hash { + if payload != body_hash { return Err(Error::PayloadHashMismatch); } } diff --git a/crates/nostr/src/nips/nipc0.rs b/crates/nostr/src/nips/nipc0.rs index e99bd9417..39d229480 100644 --- a/crates/nostr/src/nips/nipc0.rs +++ b/crates/nostr/src/nips/nipc0.rs @@ -141,7 +141,7 @@ impl CodeSnippet { let mut add_if_some = |tag: Option<TagStandard>| { if let Some(tag) = tag { - tags.push(Tag::from_standardized_without_cell(tag)); + tags.push(Tag::from_standardized(tag)); } }; diff --git a/database/nostr-database-test-suite/src/lib.rs b/database/nostr-database-test-suite/src/lib.rs index b7197fba5..f97b36bdf 100644 --- a/database/nostr-database-test-suite/src/lib.rs +++ b/database/nostr-database-test-suite/src/lib.rs @@ -401,7 +401,7 @@ macro_rules! database_unit_tests { // Create deletion event let req = EventDeletionRequest::new() - .coordinate(event.coordinate().unwrap().into_owned()); + .coordinate(event.coordinate().unwrap()); let deletion = EventBuilder::delete(req) .sign_with_keys(&keys) @@ -852,7 +852,7 @@ macro_rules! database_unit_tests { assert_eq!(event1.created_at, event2.created_at); assert_eq!(event1.created_at.as_secs(), 1754066538); assert_eq!(event1.tags.identifier(), event2.tags.identifier()); - assert_eq!(event1.tags.identifier(), Some("article-123")); + assert_eq!(event1.tags.identifier(), Some(String::from("article-123"))); // Confirm event1 has the smaller ID (lexicographically first) assert!(event1.id.to_string() < event2.id.to_string()); diff --git a/database/nostr-database/src/ext.rs b/database/nostr-database/src/ext.rs index d5a606493..9c0bad518 100644 --- a/database/nostr-database/src/ext.rs +++ b/database/nostr-database/src/ext.rs @@ -44,7 +44,7 @@ pub trait NostrDatabaseExt: NostrDatabase { .limit(1); let events: Events = self.query(filter).await?; match events.first_owned() { - Some(event) => Ok(event.tags.public_keys().copied().collect()), + Some(event) => Ok(event.tags.public_keys().collect()), None => Ok(HashSet::new()), } }) @@ -65,7 +65,7 @@ pub trait NostrDatabaseExt: NostrDatabase { Some(event) => { // Get contacts metadata let filter = Filter::new() - .authors(event.tags.public_keys().copied()) + .authors(event.tags.public_keys()) .kind(Kind::Metadata); let mut contacts: HashSet<Profile> = self .query(filter) @@ -79,7 +79,7 @@ pub trait NostrDatabaseExt: NostrDatabase { .collect(); // Extend with missing public keys - contacts.extend(event.tags.public_keys().copied().map(Profile::from)); + contacts.extend(event.tags.public_keys().map(Profile::from)); Ok(contacts.into_iter().collect()) } @@ -104,8 +104,8 @@ pub trait NostrDatabaseExt: NostrDatabase { let events: Events = self.query(filter).await?; // Extract relay list (NIP65) - match events.first_owned() { - Some(event) => Ok(nip65::extract_owned_relay_list(event).collect()), + match events.first() { + Some(event) => Ok(nip65::extract_relay_list(event).collect()), None => Ok(HashMap::new()), } }) @@ -128,11 +128,8 @@ pub trait NostrDatabaseExt: NostrDatabase { let mut map = HashMap::with_capacity(events.len()); - for event in events.into_iter() { - map.insert( - event.pubkey, - nip65::extract_owned_relay_list(event).collect(), - ); + for event in events.iter() { + map.insert(event.pubkey, nip65::extract_relay_list(event).collect()); } Ok(map) diff --git a/database/nostr-lmdb/src/store/lmdb/index.rs b/database/nostr-lmdb/src/store/lmdb/index.rs index a75c0ffaa..c9ca2565a 100644 --- a/database/nostr-lmdb/src/store/lmdb/index.rs +++ b/database/nostr-lmdb/src/store/lmdb/index.rs @@ -6,7 +6,7 @@ use core::cmp; use nostr::event::borrow::EventBorrow; -use nostr::nips::nip01::CoordinateBorrow; +use nostr::nips::nip01::Coordinate; use nostr::{EventId, PublicKey, SingleLetterTag, Timestamp}; const CREATED_AT_BE: usize = 8; @@ -262,12 +262,12 @@ pub fn make_ktc_index_key( /// ## Structure /// /// `kind(2)` + `author(32)` + `d_len(1)` + `d(182)` -pub fn make_coordinate_index_key(coordinate: &CoordinateBorrow<'_>) -> Vec<u8> { +pub fn make_coordinate_index_key(coordinate: &Coordinate) -> Vec<u8> { let mut key: Vec<u8> = Vec::with_capacity(KIND_BE + PublicKey::LEN + 1 + TAG_VALUE_PAD_LEN); key.extend(coordinate.kind.as_u16().to_be_bytes()); key.extend(coordinate.public_key.to_bytes()); - let identifier: &str = coordinate.identifier.unwrap_or_default(); + let identifier: &str = &coordinate.identifier; let dlen: usize = cmp::min(identifier.len(), TAG_VALUE_PAD_LEN); key.push(dlen as u8); diff --git a/database/nostr-lmdb/src/store/lmdb/mod.rs b/database/nostr-lmdb/src/store/lmdb/mod.rs index 8662e49ca..e0119deb2 100644 --- a/database/nostr-lmdb/src/store/lmdb/mod.rs +++ b/database/nostr-lmdb/src/store/lmdb/mod.rs @@ -508,7 +508,7 @@ impl Lmdb { if self.options.process_nip62 && event.kind == Kind::RequestToVanish { let is_targeted = event.tags.filter_standardized(TagKind::Relay).any(|tag| { matches!(tag, TagStandard::AllRelays) - || matches!(tag, TagStandard::Relay(relay) if Some(relay) == self.options.relay_url.as_ref()) + || matches!(tag, TagStandard::Relay(ref relay) if Some(relay) == self.options.relay_url.as_ref()) }); if is_targeted { @@ -1166,7 +1166,7 @@ impl Lmdb { pub(crate) fn mark_coordinate_deleted( &self, txn: &mut RwTxn, - coordinate: &CoordinateBorrow, + coordinate: &Coordinate, when: Timestamp, ) -> Result<(), Error> { let key: Vec<u8> = index::make_coordinate_index_key(coordinate); @@ -1174,10 +1174,10 @@ impl Lmdb { Ok(()) } - pub(crate) fn when_is_coordinate_deleted<'a>( + pub(crate) fn when_is_coordinate_deleted( &self, txn: &RoTxn, - coordinate: &'a CoordinateBorrow<'a>, + coordinate: &Coordinate, ) -> Result<Option<Timestamp>, Error> { let key: Vec<u8> = index::make_coordinate_index_key(coordinate); Ok(self @@ -1338,7 +1338,7 @@ impl Lmdb { return Ok(true); } - deletions_to_process.push((*id, EventIndexKeys::new(target))); + deletions_to_process.push((id, EventIndexKeys::new(target))); } } @@ -1358,13 +1358,13 @@ impl Lmdb { } // Mark deleted - self.mark_coordinate_deleted(txn, &coordinate.borrow(), event.created_at)?; + self.mark_coordinate_deleted(txn, &coordinate, event.created_at)?; // Remove events (up to the created_at of the deletion event) if coordinate.kind.is_replaceable() { - self.remove_replaceable(txn, coordinate, event.created_at)?; + self.remove_replaceable(txn, &coordinate, event.created_at)?; } else if coordinate.kind.is_addressable() { - self.remove_addressable(txn, coordinate, event.created_at)?; + self.remove_addressable(txn, &coordinate, event.created_at)?; } } diff --git a/database/nostr-memory/src/store.rs b/database/nostr-memory/src/store.rs index 55819af53..8d40374f1 100644 --- a/database/nostr-memory/src/store.rs +++ b/database/nostr-memory/src/store.rs @@ -175,7 +175,7 @@ impl MemoryStore { self.vanished_public_keys.contains(pubkey) } - fn internal_index_event(&mut self, event: &Event, now: &Timestamp) -> SaveEventStatus { + fn internal_index_event(&mut self, event: &Event, now: Timestamp) -> SaveEventStatus { // Check if was already added if self.ids.contains_key(&event.id) { return SaveEventStatus::Rejected(RejectedReason::Duplicate); @@ -193,7 +193,6 @@ impl MemoryStore { // Reject event if ADDR was deleted after it's created_at date // (non-parameterized or parameterized) if let Some(coordinate) = event.coordinate() { - let coordinate: Coordinate = coordinate.into_owned(); if let Some(time) = self.deleted_coordinates.get(&coordinate) { if &event.created_at <= time { return SaveEventStatus::Rejected(RejectedReason::Deleted); @@ -227,14 +226,14 @@ impl MemoryStore { match event.tags.identifier() { Some(identifier) => { let coordinate: Coordinate = - Coordinate::new(kind, author).identifier(identifier); + Coordinate::new(kind, author).identifier(identifier.clone()); // Check if coordinate was deleted if self.has_coordinate_been_deleted(&coordinate, &event.created_at) { status = SaveEventStatus::Rejected(RejectedReason::Deleted); } else { let params: QueryByParamReplaceable = - QueryByParamReplaceable::new(kind, author, identifier.to_string()); + QueryByParamReplaceable::new(kind, author, identifier); if let Some(ev) = self.internal_query_param_replaceable(params) { if has_event_been_replaced(ev, event) || ev.id == event.id { status = SaveEventStatus::Rejected(RejectedReason::Replaced); @@ -249,7 +248,7 @@ impl MemoryStore { } else if self.options.process_nip09 && kind == Kind::EventDeletion { // Check `e` tags for id in event.tags.event_ids() { - if let Some(ev) = self.ids.get(id) { + if let Some(ev) = self.ids.get(&id) { if ev.pubkey != author { status = SaveEventStatus::Rejected(RejectedReason::InvalidDelete); break; @@ -302,7 +301,7 @@ impl MemoryStore { } else if self.options.process_nip62 && event.kind == Kind::RequestToVanish { let is_targeted = event.tags.filter_standardized(TagKind::Relay).any(|tag| { matches!(tag, TagStandard::AllRelays) - || matches!(tag, TagStandard::Relay(relay) if Some(relay) == self.options.relay_url.as_ref()) + || matches!(tag, TagStandard::Relay(ref relay) if Some(relay) == self.options.relay_url.as_ref()) }); if is_targeted { @@ -419,7 +418,7 @@ impl MemoryStore { return SaveEventStatus::Rejected(RejectedReason::Ephemeral); } let now = Timestamp::now(); - self.internal_index_event(event, &now) + self.internal_index_event(event, now) } /// Query by public key diff --git a/database/nostr-sqlite/src/store.rs b/database/nostr-sqlite/src/store.rs index 0d91353f9..87ede43b8 100644 --- a/database/nostr-sqlite/src/store.rs +++ b/database/nostr-sqlite/src/store.rs @@ -142,17 +142,17 @@ impl NostrSqlite { fn handle_deletion_event(tx: &Transaction<'_>, event: &Event) -> Result<bool, Error> { for id in event.tags.event_ids() { - if let Some(pubkey) = Self::get_pubkey_of_event_by_id(tx, id)? { + if let Some(pubkey) = Self::get_pubkey_of_event_by_id(tx, &id)? { // Author must match if pubkey != event.pubkey { return Ok(true); } // Mark the event ID as deleted (for NIP-09 deletion events) - Self::mark_event_as_deleted(tx, id)?; + Self::mark_event_as_deleted(tx, &id)?; // Remove event from store - Self::remove_event(tx, id)?; + Self::remove_event(tx, &id)?; } } @@ -163,13 +163,13 @@ impl NostrSqlite { } // Mark deleted - Self::mark_coordinate_deleted(tx, &coordinate.borrow(), event.created_at)?; + Self::mark_coordinate_deleted(tx, &coordinate, event.created_at)?; // Remove events (up to the created_at of the deletion event) if coordinate.kind.is_replaceable() { - Self::remove_replaceable(tx, coordinate, &event.created_at)?; + Self::remove_replaceable(tx, &coordinate, &event.created_at)?; } else if coordinate.kind.is_addressable() { - Self::remove_addressable(tx, coordinate, event.created_at)?; + Self::remove_addressable(tx, &coordinate, event.created_at)?; } } @@ -282,7 +282,7 @@ impl NostrSqlite { if options.process_nip62 && event.kind == Kind::RequestToVanish { let is_targeted = event.tags.filter_standardized(TagKind::Relay).any(|tag| { matches!(tag, TagStandard::AllRelays) - || matches!(tag, TagStandard::Relay(relay) if Some(relay) == options.relay_url.as_ref()) + || matches!(tag, TagStandard::Relay(ref relay) if Some(relay) == options.relay_url.as_ref()) }); if is_targeted { @@ -324,7 +324,7 @@ impl NostrSqlite { fn mark_coordinate_deleted( tx: &Transaction<'_>, - coordinate: &CoordinateBorrow<'_>, + coordinate: &Coordinate, deleted_at: Timestamp, ) -> Result<(), Error> { tx.execute( @@ -332,7 +332,7 @@ impl NostrSqlite { params![ coordinate.public_key.as_bytes().as_slice(), coordinate.kind.as_u16() as i64, - coordinate.identifier.unwrap_or_default(), + &coordinate.identifier, deleted_at.as_secs() as i64 ], )?; @@ -350,7 +350,7 @@ impl NostrSqlite { fn when_is_coordinate_deleted( tx: &Transaction<'_>, - coordinate: &CoordinateBorrow<'_>, + coordinate: &Coordinate, ) -> Result<Option<Timestamp>, Error> { let timestamp: Option<i64> = tx .query_row( @@ -358,7 +358,7 @@ impl NostrSqlite { params![ coordinate.public_key.as_bytes().as_slice(), coordinate.kind.as_u16() as i64, - coordinate.identifier.unwrap_or_default() + &coordinate.identifier ], |row| row.get(0), ) diff --git a/gossip/nostr-gossip-memory/src/store.rs b/gossip/nostr-gossip-memory/src/store.rs index 27729d909..b9b384436 100644 --- a/gossip/nostr-gossip-memory/src/store.rs +++ b/gossip/nostr-gossip-memory/src/store.rs @@ -89,7 +89,7 @@ impl NostrGossipMemory { let mut read_write_mask: GossipFlags = GossipFlags::READ; read_write_mask.add(GossipFlags::WRITE); - match pk_data.relays.get_mut(relay_url) { + match pk_data.relays.get_mut(&relay_url) { Some(relay_data) => { // Update the bitflag: remove the previous READ and WRITE values and apply the new bitflag (preserves any other flag) relay_data.bitflags.remove(read_write_mask); @@ -110,7 +110,7 @@ impl NostrGossipMemory { public_keys.get_or_insert_mut(event.pubkey, PkData::default); for relay_url in nip17::extract_relay_list(event).take(MAX_NIP17_SIZE) { - match pk_data.relays.get_mut(relay_url) { + match pk_data.relays.get_mut(&relay_url) { Some(relay_data) => { relay_data.bitflags.add(GossipFlags::PRIVATE_MESSAGE); } @@ -118,7 +118,7 @@ impl NostrGossipMemory { let mut relay_data = PkRelayData::default(); relay_data.bitflags.add(GossipFlags::PRIVATE_MESSAGE); - pk_data.relays.insert(relay_url.clone(), relay_data); + pk_data.relays.insert(relay_url, relay_data); } } } @@ -133,7 +133,7 @@ impl NostrGossipMemory { } = tag { let pk_data: &mut PkData = - public_keys.get_or_insert_mut(*public_key, PkData::default); + public_keys.get_or_insert_mut(public_key, PkData::default); update_relay_per_user(pk_data, relay_url.clone(), GossipFlags::HINT); } } diff --git a/gossip/nostr-gossip-sqlite/src/store.rs b/gossip/nostr-gossip-sqlite/src/store.rs index 06eec39e8..533b2433c 100644 --- a/gossip/nostr-gossip-sqlite/src/store.rs +++ b/gossip/nostr-gossip-sqlite/src/store.rs @@ -490,13 +490,9 @@ fn update_relay_per_user( Ok(()) } -fn update_nip65_relays<'a, I>( - tx: &Transaction<'_>, - public_key_id: i32, - iter: I, -) -> Result<(), Error> +fn update_nip65_relays<I>(tx: &Transaction<'_>, public_key_id: i32, iter: I) -> Result<(), Error> where - I: IntoIterator<Item = (&'a RelayUrl, &'a Option<RelayMetadata>)>, + I: IntoIterator<Item = (RelayUrl, Option<RelayMetadata>)>, { // Remove all READ and WRITE flags from the relays of the public key remove_flag_from_user_relays(tx, public_key_id, READ_WRITE_FLAGS)?; @@ -504,7 +500,7 @@ where // Extract relay list for (relay_url, metadata) in iter { // Save relay and get ID - let relay_id: i32 = get_or_save_relay_url(tx, relay_url)?; + let relay_id: i32 = get_or_save_relay_url(tx, &relay_url)?; // New bitflag for the relay let bitflag: GossipFlags = match metadata { @@ -529,20 +525,16 @@ where Ok(()) } -fn update_nip17_relays<'a, I>( - tx: &Transaction<'_>, - public_key_id: i32, - iter: I, -) -> Result<(), Error> +fn update_nip17_relays<I>(tx: &Transaction<'_>, public_key_id: i32, iter: I) -> Result<(), Error> where - I: IntoIterator<Item = &'a RelayUrl>, + I: IntoIterator<Item = RelayUrl>, { // Remove all PRIVATE_MESSAGE flag from the relays of the public key remove_flag_from_user_relays(tx, public_key_id, GossipFlags::PRIVATE_MESSAGE)?; // Extract relay list for relay_url in iter { - let relay_id: i32 = get_or_save_relay_url(tx, relay_url)?; + let relay_id: i32 = get_or_save_relay_url(tx, &relay_url)?; tx.execute( r#" @@ -571,8 +563,8 @@ fn update_hints(tx: &Transaction<'_>, event: &Event) -> Result<(), Error> { .. } = tag { - let p_tag_pk_id: i32 = get_or_save_public_key(tx, public_key)?; - update_relay_per_user(tx, p_tag_pk_id, relay_url, GossipFlags::HINT)?; + let p_tag_pk_id: i32 = get_or_save_public_key(tx, &public_key)?; + update_relay_per_user(tx, p_tag_pk_id, &relay_url, GossipFlags::HINT)?; } } diff --git a/gossip/nostr-gossip-test-suite/src/lib.rs b/gossip/nostr-gossip-test-suite/src/lib.rs index cc9e1450f..2fbe9b9d8 100644 --- a/gossip/nostr-gossip-test-suite/src/lib.rs +++ b/gossip/nostr-gossip-test-suite/src/lib.rs @@ -95,7 +95,7 @@ macro_rules! gossip_unit_tests { let keys = Keys::generate(); let event = EventBuilder::text_note("test") - .tag(Tag::from_standardized_without_cell( + .tag(Tag::from_standardized( TagStandard::PublicKey { public_key, relay_url: Some(relay_url.clone()), diff --git a/sdk/src/client/api/send_event.rs b/sdk/src/client/api/send_event.rs index 7430a4eaf..ed94fe38d 100644 --- a/sdk/src/client/api/send_event.rs +++ b/sdk/src/client/api/send_event.rs @@ -251,7 +251,7 @@ async fn gossip_prepare_urls( }; // Get only p tags since the author of a gift wrap is randomized - let public_keys: BTreeSet<PublicKey> = event.tags.public_keys().copied().collect(); + let public_keys: BTreeSet<PublicKey> = event.tags.public_keys().collect(); (public_keys, &[kind]) } else if is_contact_list { @@ -262,7 +262,6 @@ async fn gossip_prepare_urls( let public_keys: BTreeSet<PublicKey> = event .tags .public_keys() - .copied() .chain(iter::once(event.pubkey)) .collect(); (public_keys, &[GossipListKind::Nip65]) diff --git a/sdk/src/client/gossip/resolver.rs b/sdk/src/client/gossip/resolver.rs index 21e9f529b..c038a6353 100644 --- a/sdk/src/client/gossip/resolver.rs +++ b/sdk/src/client/gossip/resolver.rs @@ -38,21 +38,21 @@ impl GossipRelayResolver { self.sync_counter.fetch_add(1, Ordering::SeqCst) } - pub(in crate::client) async fn get_relays<'a, I>( + pub(in crate::client) async fn get_relays<I>( &self, public_keys: I, selection: BestRelaySelection, allowed: GossipAllowedRelays, ) -> Result<HashSet<RelayUrl>, Error> where - I: IntoIterator<Item = &'a PublicKey>, + I: IntoIterator<Item = PublicKey>, { let mut urls: HashSet<RelayUrl> = HashSet::new(); for public_key in public_keys.into_iter() { let relays: HashSet<RelayUrl> = self .gossip - .get_best_relays(public_key, selection, allowed) + .get_best_relays(&public_key, selection, allowed) .await?; urls.extend(relays); } @@ -259,7 +259,7 @@ impl GossipRelayResolver { // Get map of outbox and inbox relays let mut relays: HashSet<RelayUrl> = self .get_relays( - union.iter(), + union.iter().copied(), BestRelaySelection::All { read: limits.read_relays_per_user, write: limits.write_relays_per_user, @@ -275,7 +275,7 @@ impl GossipRelayResolver { // Get map of private message relays let nip17_relays = self .get_relays( - union.iter(), + union.iter().copied(), BestRelaySelection::PrivateMessage { limit: limits.nip17_relays, }, From 4eb04a3337bec9266fb46be1ceefcc7eb2355f12 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Thu, 12 Mar 2026 10:49:52 +0100 Subject: [PATCH 02/40] nostr: add tag codec Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/codec.rs | 100 ++++++++++++++++++++++++++++ crates/nostr/src/event/tag/mod.rs | 2 + 2 files changed, 102 insertions(+) create mode 100644 crates/nostr/src/event/tag/codec.rs diff --git a/crates/nostr/src/event/tag/codec.rs b/crates/nostr/src/event/tag/codec.rs new file mode 100644 index 000000000..68f476b1e --- /dev/null +++ b/crates/nostr/src/event/tag/codec.rs @@ -0,0 +1,100 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +use core::fmt; + +use super::Tag; + +/// Tag codec error +#[derive(Debug, PartialEq, Eq)] +pub enum TagCodecError { + /// Missing value + Missing(&'static str), + /// Invalid value + Invalid(&'static str), + /// Unknown tag + Unknown, +} + +impl core::error::Error for TagCodecError {} + +impl fmt::Display for TagCodecError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing(value) => write!(f, "missing {value}"), + Self::Invalid(value) => write!(f, "{value}"), + Self::Unknown => write!(f, "unknown tag"), + } + } +} + +impl TagCodecError { + /// Missing tag kind + #[inline] + pub fn missing_tag_kind() -> Self { + Self::Missing("tag kind") + } +} + +/// Conversion contract between a typed tag representation and the generic raw [`Tag`]. +/// +/// Types implementing this trait are expected to: +/// - parse themselves from a raw tag representation +/// - convert themselves back into a raw [`Tag`] +/// +/// This trait is intended for NIP-specific tag enums and, where useful, for individual +/// tag structs that can be used independently of the enclosing enum. +pub trait TagCodec: Sized { + /// Error returned when parsing a raw tag into the typed representation fails. + type Error: core::error::Error; + + /// Parse a typed tag from a raw tag representation. + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>; + + /// Convert this typed tag into the generic raw [`Tag`] representation. + fn to_tag(&self) -> Tag; +} + +/// Implement the standard conversions for a [`TagCodec`] type. +#[macro_export] +macro_rules! impl_tag_codec_conversions { + ($ty:ty) => { + impl From<&$ty> for Tag { + #[inline] + fn from(value: &$ty) -> Self { + value.to_tag() + } + } + + impl From<$ty> for Tag { + #[inline] + fn from(value: $ty) -> Self { + value.to_tag() + } + } + + impl TryFrom<&Tag> for $ty { + type Error = <$ty as TagCodec>::Error; + + #[inline] + fn try_from(tag: &Tag) -> Result<Self, Self::Error> { + <$ty as TagCodec>::parse(tag.as_slice()) + } + } + + impl TryFrom<Tag> for $ty { + type Error = <$ty as TagCodec>::Error; + + #[inline] + fn try_from(tag: Tag) -> Result<Self, Self::Error> { + <$ty as TagCodec>::parse(tag.as_slice()) + } + } + }; +} + +pub use impl_tag_codec_conversions; diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 3f3193bce..348c870ee 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -15,12 +15,14 @@ use serde::de::Error as DeserializerError; use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +mod codec; pub mod cow; mod error; pub mod kind; pub mod list; pub mod standard; +pub use self::codec::*; pub use self::cow::CowTag; pub use self::error::Error; pub use self::kind::TagKind; From 927465f97ec8288e3a455eba87239c6fffda2e56 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Thu, 12 Mar 2026 11:11:11 +0100 Subject: [PATCH 03/40] nostr: rework NIP-01 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 4 +- crates/nostr/src/event/tag/list.rs | 55 +-- crates/nostr/src/event/tag/mod.rs | 44 +- crates/nostr/src/event/tag/standard.rs | 20 - crates/nostr/src/nips/mod.rs | 1 + .../nostr/src/nips/{nip01.rs => nip01/mod.rs} | 39 +- crates/nostr/src/nips/nip01/tags.rs | 383 ++++++++++++++++++ crates/nostr/src/nips/nipb0.rs | 3 +- crates/nostr/src/nips/util.rs | 101 +++++ 9 files changed, 565 insertions(+), 85 deletions(-) rename crates/nostr/src/nips/{nip01.rs => nip01/mod.rs} (95%) create mode 100644 crates/nostr/src/nips/nip01/tags.rs create mode 100644 crates/nostr/src/nips/util.rs diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 91431b197..ad5437c53 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1030,8 +1030,8 @@ impl EventBuilder { let badge_id = badge_definition .tags .iter() - .find_map(|t| match t.standardized() { - Some(TagStandard::Identifier(id)) => Some(id), + .find_map(|t| match t.try_into() { + Ok(Nip01Tag::Identifier(id)) => Some(id), _ => None, }) .ok_or(Error::NIP58(nip58::Error::IdentifierTagNotFound))?; diff --git a/crates/nostr/src/event/tag/list.rs b/crates/nostr/src/event/tag/list.rs index 0326c4c83..e0be5440e 100644 --- a/crates/nostr/src/event/tag/list.rs +++ b/crates/nostr/src/event/tag/list.rs @@ -24,8 +24,8 @@ use std::sync::OnceLock as OnceCell; use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use super::{Error, Tag}; -use crate::nips::nip01::Coordinate; +use super::{Error, Tag, TagCodec}; +use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::{EventId, PublicKey, SingleLetterTag, TagKind, TagStandard, Timestamp}; /// Tags Indexes @@ -417,8 +417,10 @@ impl Tags { /// Extract identifier (`d` tag), if exists. #[inline] pub fn identifier(&self) -> Option<String> { - match self.find_standardized(TagKind::d())? { - TagStandard::Identifier(identifier) => Some(identifier), + let tag: &Tag = self.find(TagKind::d())?; + + match Nip01Tag::try_from(tag) { + Ok(Nip01Tag::Identifier(identifier)) => Some(identifier), _ => None, } } @@ -443,42 +445,30 @@ impl Tags { } /// Extract public keys from `p` tags. - /// - /// This method extract only [`TagStandard::PublicKey`], [`TagStandard::PublicKeyReport`] and [`TagStandard::PublicKeyLiveEvent`] variants. #[inline] pub fn public_keys(&self) -> impl Iterator<Item = PublicKey> + '_ { - self.filter_standardized(TagKind::p()) - .filter_map(|t| match t { - TagStandard::PublicKey { public_key, .. } => Some(public_key), - TagStandard::PublicKeyReport(public_key, ..) => Some(public_key), - TagStandard::PublicKeyLiveEvent { public_key, .. } => Some(public_key), - _ => None, - }) + self.filter(TagKind::p()).filter_map(|t| { + let content = t.content()?; + PublicKey::from_hex(content).ok() + }) } /// Extract event IDs from `e` tags. - /// - /// This method extract only [`TagStandard::Event`] and [`TagStandard::EventReport`] variants. #[inline] pub fn event_ids(&self) -> impl Iterator<Item = EventId> + '_ { - self.filter_standardized(TagKind::e()) - .filter_map(|t| match t { - TagStandard::Event { event_id, .. } => Some(event_id), - TagStandard::EventReport(event_id, ..) => Some(event_id), - _ => None, - }) + self.filter(TagKind::e()).filter_map(|t| { + let content = t.content()?; + EventId::from_hex(content).ok() + }) } /// Extract coordinates from `a` tags. - /// - /// This method extract only [`TagStandard::Coordinate`] variant. #[inline] pub fn coordinates(&self) -> impl Iterator<Item = Coordinate> + '_ { - self.filter_standardized(TagKind::a()) - .filter_map(|t| match t { - TagStandard::Coordinate { coordinate, .. } => Some(coordinate), - _ => None, - }) + self.filter(TagKind::a()).filter_map(|t| { + let content = t.content()?; + Coordinate::from_kpi_format(content).ok() + }) } /// Extract hashtags from `t` tags. @@ -707,12 +697,11 @@ mod benches { for pk in pubkeys.into_iter() { // Push long p tag - let long_p_tag = Tag::from_standardized(TagStandard::PublicKey { + let long_p_tag = Nip01Tag::PublicKey { public_key: pk, - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - uppercase: false, - alias: None, - }); + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), + } + .to_tag(); tags.push(long_p_tag) } diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 348c870ee..c5ad249c6 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -29,7 +29,7 @@ pub use self::kind::TagKind; pub use self::list::Tags; pub use self::standard::TagStandard; use super::id::EventId; -use crate::nips::nip01::Coordinate; +use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip10::Marker; use crate::nips::nip56::Report; use crate::nips::nip65::RelayMetadata; @@ -90,7 +90,7 @@ impl IndexMut<usize> for Tag { impl Tag { #[inline] - fn new(buf: Vec<String>) -> Self { + pub(crate) fn new(buf: Vec<String>) -> Self { // The tag must not be empty! assert!(!buf.is_empty()); @@ -249,8 +249,13 @@ impl Tag { /// /// <https://github.com/nostr-protocol/nips/blob/master/01.md> #[inline] - pub fn event(event_id: EventId) -> Self { - Self::from_standardized(TagStandard::event(event_id)) + pub fn event(id: EventId) -> Self { + Nip01Tag::Event { + id, + relay_hint: None, + public_key: None, + } + .to_tag() } /// Compose `["p", "<public-key>"]` tag @@ -258,7 +263,11 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/01.md> #[inline] pub fn public_key(public_key: PublicKey) -> Self { - Self::from_standardized(TagStandard::public_key(public_key)) + Nip01Tag::PublicKey { + public_key, + relay_hint: None, + } + .to_tag() } /// Compose `["d", "<identifier>"]` tag @@ -269,7 +278,7 @@ impl Tag { where T: Into<String>, { - Self::from_standardized(TagStandard::Identifier(identifier.into())) + Nip01Tag::Identifier(identifier.into()).to_tag() } /// Compose `["a", "<coordinate>", "<optional-relay-url>"]` tag @@ -277,11 +286,11 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/01.md> #[inline] pub fn coordinate(coordinate: Coordinate, relay_url: Option<RelayUrl>) -> Self { - Self::from_standardized(TagStandard::Coordinate { + Nip01Tag::Coordinate { coordinate, - relay_url, - uppercase: false, - }) + relay_hint: relay_url, + } + .to_tag() } /// Compose `["nonce", "<nonce>", "<difficulty>"]` tag @@ -551,21 +560,6 @@ mod tests { ); } - #[test] - fn test_tag_match_standardized() { - let tag: Tag = Tag::parse(["d", "bravery"]).unwrap(); - assert_eq!( - tag.standardized(), - Some(TagStandard::Identifier(String::from("bravery"))) - ); - - let tag: Tag = Tag::parse(["d", "test"]).unwrap(); - assert_eq!( - tag.standardized(), - Some(TagStandard::Identifier(String::from("test"))) - ); - } - #[test] fn test_extract_tag_content() { let t: Tag = Tag::parse(["aaaaaa", "bbbbbb"]).unwrap(); diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 7d00c4213..8ff589fe8 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -131,7 +131,6 @@ pub enum TagStandard { }, Hashtag(String), Geohash(String), - Identifier(String), /// External Content ID /// /// <https://github.com/nostr-protocol/nips/blob/master/73.md> @@ -437,10 +436,6 @@ impl TagStandard { character: Alphabet::G, uppercase: false, }) => Ok(Self::Geohash(tag_1.to_string())), - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::D, - uppercase: false, - }) => Ok(Self::Identifier(tag_1.to_string())), TagKind::SingleLetter(SingleLetterTag { character: Alphabet::M, uppercase: false, @@ -654,10 +649,6 @@ impl TagStandard { character: Alphabet::G, uppercase: false, }), - Self::Identifier(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::D, - uppercase: false, - }), Self::ExternalContent { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::I, uppercase: *uppercase, @@ -900,7 +891,6 @@ impl From<TagStandard> for Vec<String> { } TagStandard::Hashtag(t) => vec![tag_kind, t], TagStandard::Geohash(g) => vec![tag_kind, g], - TagStandard::Identifier(d) => vec![tag_kind, d], TagStandard::Coordinate { coordinate, relay_url, @@ -1796,11 +1786,6 @@ mod tests { TagStandard::Subject(String::from("textnote with subject")).to_vec() ); - assert_eq!( - vec!["d", "test"], - TagStandard::Identifier(String::from("test")).to_vec() - ); - assert_eq!( vec![ "p", @@ -2437,11 +2422,6 @@ mod tests { TagStandard::Subject(String::from("textnote with subject")) ); - assert_eq!( - TagStandard::parse(&["d", "test"]).unwrap(), - TagStandard::Identifier(String::from("test")) - ); - assert_eq!( TagStandard::parse(&["r", "https://example.com"]).unwrap(), TagStandard::Reference(String::from("https://example.com")) diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index ada200591..44ae59eed 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -55,3 +55,4 @@ pub mod nip94; pub mod nip98; pub mod nipb0; pub mod nipc0; +mod util; diff --git a/crates/nostr/src/nips/nip01.rs b/crates/nostr/src/nips/nip01/mod.rs similarity index 95% rename from crates/nostr/src/nips/nip01.rs rename to crates/nostr/src/nips/nip01/mod.rs index 321cbaf35..2893eb38b 100644 --- a/crates/nostr/src/nips/nip01.rs +++ b/crates/nostr/src/nips/nip01/mod.rs @@ -2,7 +2,7 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP01: Basic protocol flow description +//! NIP-01: Basic protocol flow description //! //! <https://github.com/nostr-protocol/nips/blob/master/01.md> @@ -17,18 +17,28 @@ use serde::ser::{SerializeMap, Serializer}; use serde::{Deserialize, Serialize}; use serde_json::Value; +mod tags; + +pub use self::tags::*; use super::nip19::{self, FromBech32, Nip19Coordinate, ToBech32}; use super::nip21::{FromNostrUri, ToNostrUri}; -use crate::types::Url; -use crate::{Filter, JsonUtil, Kind, PublicKey, Tag, key}; +use crate::event::tag::TagCodecError; +use crate::types::url::{self, Url}; +use crate::{Filter, JsonUtil, Kind, PublicKey, Tag, event, key}; -/// Raw Event error +/// NIP-01 error #[derive(Debug, PartialEq)] pub enum Error { /// Keys error Keys(key::Error), + /// Event error + Event(event::Error), + /// Url error + Url(url::Error), /// Parse Int error ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), /// Invalid coordinate InvalidCoordinate, } @@ -39,7 +49,10 @@ impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Keys(e) => e.fmt(f), + Self::Event(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidCoordinate => f.write_str("Invalid coordinate"), } } @@ -51,12 +64,30 @@ impl From<key::Error> for Error { } } +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + impl From<ParseIntError> for Error { fn from(e: ParseIntError) -> Self { Self::ParseInt(e) } } +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Coordinate for event (`a` tag) #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct Coordinate { diff --git a/crates/nostr/src/nips/nip01/tags.rs b/crates/nostr/src/nips/nip01/tags.rs new file mode 100644 index 000000000..c2129fd8f --- /dev/null +++ b/crates/nostr/src/nips/nip01/tags.rs @@ -0,0 +1,383 @@ +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; + +use super::super::util::{ + take_and_parse_optional_public_key, take_and_parse_optional_relay_url, take_coordinate, + take_event_id, take_public_key, take_string, +}; +use super::{Coordinate, Error}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::{EventId, PublicKey, RelayUrl}; + +/// Standardized NIP-01 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/01.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip01Tag { + /// `a` tag + Coordinate { + /// Coordinate + coordinate: Coordinate, + /// Relay hint (optional but recommended) + relay_hint: Option<RelayUrl>, + }, + /// `e` tag + Event { + /// Event ID + id: EventId, + /// Relay hint (optional but recommended) + relay_hint: Option<RelayUrl>, + /// Public key hint + public_key: Option<PublicKey>, + }, + /// `d` tag + Identifier(String), + /// `p` tag + PublicKey { + /// Public key + public_key: PublicKey, + /// Relay hint (optional but recommended) + relay_hint: Option<RelayUrl>, + }, +} + +impl TagCodec for Nip01Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + "a" => { + let (coordinate, relay_hint) = parse_a_tag(iter)?; + Ok(Self::Coordinate { + coordinate, + relay_hint, + }) + } + "e" => { + let (id, relay_hint, public_key) = parse_e_tag(iter)?; + Ok(Self::Event { + id, + relay_hint, + public_key, + }) + } + "d" => Ok(Self::Identifier(take_string(&mut iter, "identifier")?)), + "p" => { + let (public_key, relay_hint) = parse_p_tag(iter)?; + Ok(Self::PublicKey { + public_key, + relay_hint, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Coordinate { + coordinate, + relay_hint, + } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + + tag.push(String::from("a")); + tag.push(coordinate.to_string()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) + } + Self::Event { + id, + relay_hint, + public_key, + } => { + let mut tag: Vec<String> = Vec::with_capacity( + 2 + relay_hint.is_some() as usize + public_key.is_some() as usize, + ); + + tag.push(String::from("e")); + tag.push(id.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } else if public_key.is_some() { + tag.push(String::new()); + } + + if let Some(public_key) = public_key { + tag.push(public_key.to_string()); + } + + Tag::new(tag) + } + Self::Identifier(identifier) => { + Tag::new(vec![String::from("d"), identifier.to_string()]) + } + Self::PublicKey { + public_key, + relay_hint, + } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + + tag.push(String::from("p")); + tag.push(public_key.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip01Tag); + +fn parse_a_tag<T, S>(mut iter: T) -> Result<(Coordinate, Option<RelayUrl>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let coordinate: Coordinate = take_coordinate::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + Ok((coordinate, relay_hint)) +} + +fn parse_e_tag<T, S>(mut iter: T) -> Result<(EventId, Option<RelayUrl>, Option<PublicKey>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let public_key: Option<PublicKey> = take_and_parse_optional_public_key(&mut iter)?; + + Ok((id, relay_hint, public_key)) +} + +fn parse_p_tag<T, S>(mut iter: T) -> Result<(PublicKey, Option<RelayUrl>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + Ok((public_key, relay_hint)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{event, key}; + + #[test] + fn test_standardized_a_tag() { + let raw = "30617:00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9:n34"; + let coordinate = Coordinate::from_kpi_format(raw).unwrap(); + + // Simple + let tag = vec!["a", raw]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Coordinate { + coordinate: coordinate.clone(), + relay_hint: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint + let tag = vec!["a", raw, "wss://relay.damus.io/"]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Coordinate { + coordinate, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid coordinate + let tag = vec!["a", "hello"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::InvalidCoordinate); + + // Missing coordinate + let tag = vec!["a"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("coordinate"))); + } + + #[test] + fn test_standardized_e_tag() { + let raw = "a3ce0a22c5c25e5a41a17004d38ed2aa8f815dda918c92400c6b611c41acbc78"; + let id = EventId::from_hex(raw).unwrap(); + + // Simple + let tag = vec!["e", raw]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Event { + id, + relay_hint: None, + public_key: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint + let tag = vec!["e", raw, "wss://relay.damus.io/"]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Event { + id, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), + public_key: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint and public key + let tag = vec![ + "e", + raw, + "wss://relay.damus.io/", + "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9", + ]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Event { + id, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), + public_key: Some( + PublicKey::from_hex( + "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9" + ) + .unwrap() + ) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With public key and no relay hint + let tag = vec![ + "e", + raw, + "", + "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9", + ]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::Event { + id, + relay_hint: None, + public_key: Some( + PublicKey::from_hex( + "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9" + ) + .unwrap() + ) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid ID + let tag = vec!["e", "hello"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, Error::Event(event::Error::Hex(_)))); + + // Missing ID + let tag = vec!["e"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("event ID"))); + + // Issue: https://gitworkshop.dev/yukikishimoto.com/nostr/issues/note15xl8ae8dnmt26adfw6ec8gshxxs242vrvsa3v36ctwq2x9gglkustlxlwa + let result = Nip01Tag::parse(["e", raw, "", "", ""]).unwrap(); + assert_eq!( + result, + Nip01Tag::Event { + id, + relay_hint: None, + public_key: None, + } + ) + } + + #[test] + fn test_standardized_d_tag() { + let tag = vec!["d", "raw"]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip01Tag::Identifier(String::from("raw"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Missing identifier + let tag = vec!["d"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("identifier"))); + } + + #[test] + fn test_standardized_p_tag() { + let raw = "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9"; + let public_key = PublicKey::from_hex(raw).unwrap(); + + // Simple + let tag = vec!["p", raw]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::PublicKey { + public_key, + relay_hint: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint + let tag = vec!["p", raw, "wss://relay.damus.io/"]; + let parsed = Nip01Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip01Tag::PublicKey { + public_key, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid public key + let tag = vec!["p", "hello"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, Error::Keys(key::Error::Hex(_)))); + + // Missing public key + let tag = vec!["p"]; + let err = Nip01Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("public key"))); + } +} diff --git a/crates/nostr/src/nips/nipb0.rs b/crates/nostr/src/nips/nipb0.rs index 8699f86bb..7950ee3f3 100644 --- a/crates/nostr/src/nips/nipb0.rs +++ b/crates/nostr/src/nips/nipb0.rs @@ -9,6 +9,7 @@ use alloc::string::String; use alloc::vec::Vec; +use super::nip01::Nip01Tag; use crate::{EventBuilder, Kind, Tag, TagStandard, Timestamp}; /// Web Bookmark @@ -74,7 +75,7 @@ impl WebBookmark { /// Convert the web bookmark to an event builder #[allow(clippy::wrong_self_convention)] pub(crate) fn to_event_builder(self) -> EventBuilder { - let mut tags: Vec<Tag> = vec![TagStandard::Identifier(self.url).into()]; + let mut tags: Vec<Tag> = vec![Nip01Tag::Identifier(self.url).into()]; let mut add_if_some = |tag: Option<TagStandard>| { if let Some(tag) = tag { diff --git a/crates/nostr/src/nips/util.rs b/crates/nostr/src/nips/util.rs new file mode 100644 index 000000000..0b560741f --- /dev/null +++ b/crates/nostr/src/nips/util.rs @@ -0,0 +1,101 @@ +use alloc::string::{String, ToString}; + +use super::nip01::{self, Coordinate}; +use crate::event::tag::TagCodecError; +use crate::event::{self, EventId}; +use crate::key::{self, PublicKey}; +use crate::types::url::{self, RelayUrl}; + +#[inline] +pub(super) fn take_and_parse_optional_public_key<I, S>( + iter: &mut I, +) -> Result<Option<PublicKey>, key::Error> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + take_and_parse_optional(iter, PublicKey::from_hex) +} + +#[inline] +pub(super) fn take_and_parse_optional_relay_url<I, S>( + iter: &mut I, +) -> Result<Option<RelayUrl>, url::Error> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + take_and_parse_optional(iter, RelayUrl::parse) +} + +/// Take and parse an **optional** value with the provided parser. +/// +/// If the value is missing or empty, `None` is returned. +fn take_and_parse_optional<I, S, T, E>( + iter: &mut I, + parse: impl FnOnce(&str) -> Result<T, E>, +) -> Result<Option<T>, E> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + match iter.next() { + Some(value) => { + let value: &str = value.as_ref(); + + if value.is_empty() { + Ok(None) + } else { + // NOTE: we don't use FromStr::from_str here because some implementations, like PublicKey::from_str, support parsing both of hex and also bech32 or URIs, but tags must use just hex. + parse(value).map(Some) + } + } + None => Ok(None), + } +} + +/// Take a string +pub(super) fn take_string<I, S>( + iter: &mut I, + missing_error: &'static str, +) -> Result<String, TagCodecError> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + let value: S = iter.next().ok_or(TagCodecError::Missing(missing_error))?; + Ok(value.as_ref().to_string()) +} + +pub(super) fn take_public_key<I, S, E>(iter: &mut I) -> Result<PublicKey, E> +where + I: Iterator<Item = S>, + S: AsRef<str>, + E: From<key::Error> + From<TagCodecError>, +{ + let public_key: S = iter.next().ok_or(TagCodecError::Missing("public key"))?; + let public_key: PublicKey = PublicKey::from_hex(public_key.as_ref())?; + Ok(public_key) +} + +pub(super) fn take_coordinate<I, S, E>(iter: &mut I) -> Result<Coordinate, E> +where + I: Iterator<Item = S>, + S: AsRef<str>, + E: From<nip01::Error> + From<TagCodecError>, +{ + let coordinate: S = iter.next().ok_or(TagCodecError::Missing("coordinate"))?; + let coordinate: Coordinate = Coordinate::from_kpi_format(coordinate.as_ref())?; + Ok(coordinate) +} + +pub(super) fn take_event_id<I, S, E>(iter: &mut I) -> Result<EventId, E> +where + I: Iterator<Item = S>, + S: AsRef<str>, + E: From<event::Error> + From<TagCodecError>, +{ + let event_id: S = iter.next().ok_or(TagCodecError::Missing("event ID"))?; + let event_id: EventId = EventId::from_hex(event_id.as_ref())?; + Ok(event_id) +} From 318bf4c7ca8afdbed67eddb6ae130bb9d7609b2e Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Thu, 12 Mar 2026 11:32:49 +0100 Subject: [PATCH 04/40] nostr: rework NIP-02 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 9 +- crates/nostr/src/event/tag/list.rs | 1 - crates/nostr/src/event/tag/standard.rs | 71 +------ crates/nostr/src/nips/nip02.rs | 222 +++++++++++++++++++++- crates/nostr/src/nips/nip22.rs | 3 - crates/nostr/src/nips/nip25.rs | 1 - crates/nostr/src/nips/util.rs | 19 ++ gossip/nostr-gossip-test-suite/src/lib.rs | 1 - 8 files changed, 248 insertions(+), 79 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index ad5437c53..6c969d419 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -505,12 +505,12 @@ impl EventBuilder { I: IntoIterator<Item = Contact>, { let tags = contacts.into_iter().map(|contact| { - Tag::from_standardized(TagStandard::PublicKey { + Nip02Tag::PublicKey { public_key: contact.public_key, - relay_url: contact.relay_url, + relay_hint: contact.relay_url, alias: contact.alias, - uppercase: false, - }) + } + .to_tag() }); Self::new(Kind::ContactList, "").tags(tags) } @@ -941,7 +941,6 @@ impl EventBuilder { tags.push(Tag::from_standardized(TagStandard::PublicKey { public_key: zap_request.pubkey, relay_url: None, - alias: None, uppercase: true, })); diff --git a/crates/nostr/src/event/tag/list.rs b/crates/nostr/src/event/tag/list.rs index e0be5440e..ae5e6f0b6 100644 --- a/crates/nostr/src/event/tag/list.rs +++ b/crates/nostr/src/event/tag/list.rs @@ -608,7 +608,6 @@ mod tests { public_key: pubkey1, relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), uppercase: false, - alias: None, }); let long_e_tag_2 = Tag::from_standardized(TagStandard::Event { diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 8ff589fe8..68658dfec 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -107,7 +107,6 @@ pub enum TagStandard { PublicKey { public_key: PublicKey, relay_url: Option<RelayUrl>, - alias: Option<String>, /// Whether the tag is an uppercase or not uppercase: bool, }, @@ -585,7 +584,6 @@ impl TagStandard { Self::PublicKey { public_key, relay_url: None, - alias: None, uppercase: false, } } @@ -815,17 +813,12 @@ impl From<TagStandard> for Vec<String> { TagStandard::PublicKey { public_key, relay_url, - alias, .. } => { let mut tag = vec![tag_kind, public_key.to_string()]; if let Some(relay_url) = relay_url { tag.push(relay_url.to_string()); } - if let Some(alias) = alias { - tag.resize_with(3, String::new); - tag.push(alias); - } tag } TagStandard::EventReport(id, report) => { @@ -1272,20 +1265,13 @@ where None }; - return match LiveEventMarker::from_str(tag_3) { - Ok(marker) => Ok(TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker, - proof: None, - }), - Err(_) => Ok(TagStandard::PublicKey { - public_key, - relay_url, - alias: (!tag_3.is_empty()).then_some(tag_3.to_string()), - uppercase, - }), - }; + let marker = LiveEventMarker::from_str(tag_3)?; + return Ok(TagStandard::PublicKeyLiveEvent { + public_key, + relay_url, + marker, + proof: None, + }); } if tag.len() >= 3 && !uppercase { @@ -1295,7 +1281,6 @@ where Ok(TagStandard::PublicKey { public_key, relay_url: None, - alias: None, uppercase, }) } else { @@ -1304,7 +1289,6 @@ where Err(_) => Ok(TagStandard::PublicKey { public_key, relay_url: Some(RelayUrl::parse(tag_2)?), - alias: None, uppercase, }), } @@ -1314,7 +1298,6 @@ where Ok(TagStandard::PublicKey { public_key, relay_url: None, - alias: None, uppercase, }) } else { @@ -1798,7 +1781,6 @@ mod tests { ) .unwrap(), relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - alias: None, uppercase: false, } .to_vec() @@ -2048,25 +2030,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io", - "alias", - ], - TagStandard::PublicKey { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - alias: Some(String::from("alias")), - uppercase: false, - } - .to_vec() - ); - assert_eq!( vec![ "e", @@ -2479,7 +2442,6 @@ mod tests { ) .unwrap(), relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - alias: None, uppercase: false } ); @@ -2745,25 +2707,6 @@ mod tests { } ); - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io/", - "alias", - ]) - .unwrap(), - TagStandard::PublicKey { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), - alias: Some(String::from("alias")), - uppercase: false, - } - ); - assert_eq!( TagStandard::parse(&[ "e", diff --git a/crates/nostr/src/nips/nip02.rs b/crates/nostr/src/nips/nip02.rs index 68504f742..194f1d035 100644 --- a/crates/nostr/src/nips/nip02.rs +++ b/crates/nostr/src/nips/nip02.rs @@ -2,14 +2,59 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP02: Follow List +//! NIP-02: Follow List //! //! <https://github.com/nostr-protocol/nips/blob/master/02.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; -use crate::key::PublicKey; -use crate::types::RelayUrl; +use super::util::{take_and_parse_optional_relay_url, take_optional_string, take_public_key}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::key::{self, PublicKey}; +use crate::types::url::{self, RelayUrl}; + +/// NIP-02 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Keys error + Keys(key::Error), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Keys(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} /// Contact #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -33,3 +78,172 @@ impl Contact { } } } + +impl From<Contact> for Nip02Tag { + fn from(contact: Contact) -> Self { + Self::PublicKey { + public_key: contact.public_key, + relay_hint: contact.relay_url, + alias: contact.alias, + } + } +} + +/// Standardized NIP-02 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/02.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip02Tag { + /// Contact public key + /// + /// `["p", <32-bytes hex key>, <main relay URL>, <petname>]` + PublicKey { + /// Public key + public_key: PublicKey, + /// Recommended relay URL + relay_hint: Option<RelayUrl>, + /// Alias + alias: Option<String>, + }, +} + +impl TagCodec for Nip02Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + "p" => { + let (public_key, relay_hint, alias) = parse_p_tag(iter)?; + Ok(Self::PublicKey { + public_key, + relay_hint, + alias, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + let Self::PublicKey { + public_key, + relay_hint, + alias, + } = self; + + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + + tag.push(String::from("p")); + tag.push(public_key.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } else if alias.is_some() { + tag.push(String::new()); + } + + if let Some(alias) = alias { + tag.push(alias.to_string()); + } + + Tag::new(tag) + } +} + +impl_tag_codec_conversions!(Nip02Tag); + +fn parse_p_tag<T, S>(mut iter: T) -> Result<(PublicKey, Option<RelayUrl>, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let alias: Option<String> = take_optional_string(&mut iter); + + Ok((public_key, relay_hint, alias)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_standardized_p_tag() { + let raw = "00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9"; + let public_key = PublicKey::from_hex(raw).unwrap(); + + // Simple + let tag = vec!["p", raw]; + let parsed = Nip02Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip02Tag::PublicKey { + public_key, + relay_hint: None, + alias: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint + let tag = vec!["p", raw, "wss://relay.damus.io/"]; + let parsed = Nip02Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip02Tag::PublicKey { + public_key, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), + alias: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With relay hint and alias + let tag = vec!["p", raw, "wss://relay.damus.io/", "alice"]; + let parsed = Nip02Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip02Tag::PublicKey { + public_key, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io/").unwrap()), + alias: Some(String::from("alice")) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // With alias and no relay hint + let tag = vec!["p", raw, "", "alice"]; + let parsed = Nip02Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip02Tag::PublicKey { + public_key, + relay_hint: None, + alias: Some(String::from("alice")) + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid public key + let tag = vec!["p", "hello"]; + let err = Nip02Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, Error::Keys(key::Error::Hex(_)))); + + // Missing public key + let tag = vec!["p"]; + let err = Nip02Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("public key"))); + } +} diff --git a/crates/nostr/src/nips/nip22.rs b/crates/nostr/src/nips/nip22.rs index a9de0c7a0..ce05c4965 100644 --- a/crates/nostr/src/nips/nip22.rs +++ b/crates/nostr/src/nips/nip22.rs @@ -140,7 +140,6 @@ impl<'a> CommentTarget<'a> { tags.push(Tag::from_standardized(TagStandard::PublicKey { public_key: *pubkey, relay_url: relay_hint.clone().map(|r| r.into_owned()), - alias: None, uppercase: is_root, })); } @@ -169,7 +168,6 @@ impl<'a> CommentTarget<'a> { tags.push(Tag::from_standardized(TagStandard::PublicKey { public_key, relay_url: relay_hint.clone().map(|r| r.into_owned()), - alias: None, uppercase: is_root, })); tags.push(Tag::from_standardized(TagStandard::Kind { @@ -371,7 +369,6 @@ mod tests { tags.contains(&Tag::from_standardized(TagStandard::PublicKey { public_key, relay_url: None, - alias: None, uppercase })) ); diff --git a/crates/nostr/src/nips/nip25.rs b/crates/nostr/src/nips/nip25.rs index ca3c3a6ef..e84602a63 100644 --- a/crates/nostr/src/nips/nip25.rs +++ b/crates/nostr/src/nips/nip25.rs @@ -58,7 +58,6 @@ impl ReactionTarget { tags.push(Tag::from_standardized(TagStandard::PublicKey { public_key: self.public_key, relay_url: self.relay_hint, - alias: None, uppercase: false, })); diff --git a/crates/nostr/src/nips/util.rs b/crates/nostr/src/nips/util.rs index 0b560741f..6cbe76cc1 100644 --- a/crates/nostr/src/nips/util.rs +++ b/crates/nostr/src/nips/util.rs @@ -54,6 +54,25 @@ where } } +/// Take an **optional** string +/// +/// If the value is empty, None is returned. +pub(super) fn take_optional_string<I, S>(iter: &mut I) -> Option<String> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + iter.next().and_then(|value| { + let value: &str = value.as_ref(); + + if value.is_empty() { + None + } else { + Some(value.to_string()) + } + }) +} + /// Take a string pub(super) fn take_string<I, S>( iter: &mut I, diff --git a/gossip/nostr-gossip-test-suite/src/lib.rs b/gossip/nostr-gossip-test-suite/src/lib.rs index 2fbe9b9d8..b352e3340 100644 --- a/gossip/nostr-gossip-test-suite/src/lib.rs +++ b/gossip/nostr-gossip-test-suite/src/lib.rs @@ -99,7 +99,6 @@ macro_rules! gossip_unit_tests { TagStandard::PublicKey { public_key, relay_url: Some(relay_url.clone()), - alias: None, uppercase: false, }, )) From bb79002635be38468c396b707178c7b9c8fc6166 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Thu, 12 Mar 2026 12:01:23 +0100 Subject: [PATCH 05/40] nostr: rework NIP-17 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 2 +- crates/nostr/src/nips/nip17.rs | 141 ++++++++++++++++++++++++++++-- crates/nostr/src/nips/util.rs | 11 +++ 3 files changed, 144 insertions(+), 10 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 6c969d419..dc97439b0 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1382,7 +1382,7 @@ impl EventBuilder { where I: IntoIterator<Item = RelayUrl>, { - Self::new(Kind::InboxRelays, "").tags(urls.into_iter().map(Tag::relay)) + Self::new(Kind::InboxRelays, "").tags(urls.into_iter().map(Nip17Tag::Relay).map(Into::into)) } /// Private Direct message rumor diff --git a/crates/nostr/src/nips/nip17.rs b/crates/nostr/src/nips/nip17.rs index ae7394596..2daaf242f 100644 --- a/crates/nostr/src/nips/nip17.rs +++ b/crates/nostr/src/nips/nip17.rs @@ -2,21 +2,144 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP17: Private Direct Message +//! NIP-17: Private Direct Message //! //! <https://github.com/nostr-protocol/nips/blob/master/17.md> -use crate::{Event, RelayUrl, TagStandard}; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::take_relay_url; +use crate::types::url; +use crate::{Event, RelayUrl}; + +const RELAY: &str = "relay"; + +/// NIP-17 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-17 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/17.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip17Tag { + /// Relay + /// + /// `["relay", <relay URL>]` + Relay(RelayUrl), +} + +impl TagCodec for Nip17Tag { + type Error = Error; + + /// Parse NIP-17 standardized tag + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + RELAY => { + let url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + Ok(Self::Relay(url)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + let Self::Relay(url) = self; + let tag: Vec<String> = vec![String::from(RELAY), url.to_string()]; + Tag::new(tag) + } +} + +impl_tag_codec_conversions!(Nip17Tag); /// Extracts the relay list /// /// This function doesn't verify if the event kind is [`Kind::InboxRelays`](crate::Kind::InboxRelays)! pub fn extract_relay_list(event: &Event) -> impl Iterator<Item = RelayUrl> + '_ { - event.tags.iter().filter_map(|tag| { - if let Some(TagStandard::Relay(url)) = tag.standardized() { - Some(url) - } else { - None - } - }) + event + .tags + .iter() + .filter_map(|tag| match Nip17Tag::parse(tag.as_slice()) { + Ok(Nip17Tag::Relay(url)) => Some(url), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_empty_tag() { + let tag: Vec<String> = Vec::new(); + let err = Nip17Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::missing_tag_kind())); + } + + #[test] + fn test_non_existing_tag() { + let tag = vec!["p"]; + let err = Nip17Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Unknown)); + } + + #[test] + fn test_standardized_relay_tag() { + let relay = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let tag = vec!["relay".to_string(), relay.to_string()]; + + let parsed = Nip17Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip17Tag::Relay(relay.clone())); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_missing_relay_url() { + let tag = vec!["relay"]; + let err = Nip17Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("relay URL"))); + } } diff --git a/crates/nostr/src/nips/util.rs b/crates/nostr/src/nips/util.rs index 6cbe76cc1..60525423a 100644 --- a/crates/nostr/src/nips/util.rs +++ b/crates/nostr/src/nips/util.rs @@ -118,3 +118,14 @@ where let event_id: EventId = EventId::from_hex(event_id.as_ref())?; Ok(event_id) } + +pub(super) fn take_relay_url<T, S, E>(iter: &mut T) -> Result<RelayUrl, E> +where + T: Iterator<Item = S>, + S: AsRef<str>, + E: From<url::Error> + From<TagCodecError>, +{ + let relay_url: S = iter.next().ok_or(TagCodecError::Missing("relay URL"))?; + let relay_url: RelayUrl = RelayUrl::parse(relay_url.as_ref())?; + Ok(relay_url) +} From da8e43bf7b00fb9d5dd60317afec6d58549e51ad Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Fri, 13 Mar 2026 09:59:04 +0100 Subject: [PATCH 06/40] nostr: rework NIP-40 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/list.rs | 7 +- crates/nostr/src/event/tag/mod.rs | 3 +- crates/nostr/src/event/tag/standard.rs | 16 --- crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip40.rs | 134 +++++++++++++++++++++++++ crates/nostr/src/nips/util.rs | 14 +++ crates/nostr/src/prelude.rs | 1 + 7 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 crates/nostr/src/nips/nip40.rs diff --git a/crates/nostr/src/event/tag/list.rs b/crates/nostr/src/event/tag/list.rs index ae5e6f0b6..5ae0680f2 100644 --- a/crates/nostr/src/event/tag/list.rs +++ b/crates/nostr/src/event/tag/list.rs @@ -26,6 +26,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::{Error, Tag, TagCodec}; use crate::nips::nip01::{Coordinate, Nip01Tag}; +use crate::nips::nip40::Nip40Tag; use crate::{EventId, PublicKey, SingleLetterTag, TagKind, TagStandard, Timestamp}; /// Tags Indexes @@ -429,8 +430,10 @@ impl Tags { /// /// <https://github.com/nostr-protocol/nips/blob/master/40.md> pub fn expiration(&self) -> Option<Timestamp> { - match self.find_standardized(TagKind::Expiration)? { - TagStandard::Expiration(timestamp) => Some(timestamp), + let tag: &Tag = self.find(TagKind::Expiration)?; + + match Nip40Tag::try_from(tag) { + Ok(Nip40Tag::Expiration(expiration)) => Some(expiration), _ => None, } } diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index c5ad249c6..6cd6f3a23 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -31,6 +31,7 @@ pub use self::standard::TagStandard; use super::id::EventId; use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip10::Marker; +use crate::nips::nip40::Nip40Tag; use crate::nips::nip56::Report; use crate::nips::nip65::RelayMetadata; use crate::types::Url; @@ -319,7 +320,7 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/40.md> #[inline] pub fn expiration(timestamp: Timestamp) -> Self { - Self::from_standardized(TagStandard::Expiration(timestamp)) + Nip40Tag::Expiration(timestamp).to_tag() } /// Compose `["e", "<event-id>", "<report>"]` tag diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 68658dfec..25dc743e3 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -201,7 +201,6 @@ pub enum TagStandard { ContentWarning { reason: Option<String>, }, - Expiration(Timestamp), Subject(String), Challenge(String), Title(String), @@ -455,7 +454,6 @@ impl TagStandard { Ok(Self::Relay(RelayUrl::parse(tag_1)?)) } } - TagKind::Expiration => Ok(Self::Expiration(Timestamp::from_str(tag_1)?)), TagKind::Extension => Ok(Self::Extension(tag_1.to_string())), TagKind::License => Ok(Self::License(tag_1.to_string())), // TODO: depending on the event kind, handle the tag in the right way. @@ -673,7 +671,6 @@ impl TagStandard { Self::POW { .. } => TagKind::Nonce, Self::Client { .. } => TagKind::Client, Self::ContentWarning { .. } => TagKind::ContentWarning, - Self::Expiration(..) => TagKind::Expiration, Self::Subject(..) => TagKind::Subject, Self::Challenge(..) => TagKind::Challenge, Self::Title(..) => TagKind::Title, @@ -942,9 +939,6 @@ impl From<TagStandard> for Vec<String> { } tag } - TagStandard::Expiration(timestamp) => { - vec![tag_kind, timestamp.to_string()] - } TagStandard::Subject(sub) => vec![tag_kind, sub], TagStandard::Challenge(challenge) => vec![tag_kind, challenge], TagStandard::Title(title) => vec![tag_kind, title], @@ -1751,11 +1745,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec!["expiration", "1600000000"], - TagStandard::Expiration(Timestamp::from(1600000000)).to_vec() - ); - assert_eq!( vec!["content-warning", "reason"], TagStandard::ContentWarning { @@ -2368,11 +2357,6 @@ mod tests { } ); - assert_eq!( - TagStandard::parse(&["expiration", "1600000000"]).unwrap(), - TagStandard::Expiration(Timestamp::from(1600000000)) - ); - assert_eq!( TagStandard::parse(&["content-warning", "reason"]).unwrap(), TagStandard::ContentWarning { diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index 44ae59eed..f4e86f26b 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -26,6 +26,7 @@ pub mod nip34; pub mod nip35; pub mod nip38; pub mod nip39; +pub mod nip40; pub mod nip42; pub mod nip44; #[cfg(all(feature = "std", feature = "nip46"))] diff --git a/crates/nostr/src/nips/nip40.rs b/crates/nostr/src/nips/nip40.rs new file mode 100644 index 000000000..d81b2ff7f --- /dev/null +++ b/crates/nostr/src/nips/nip40.rs @@ -0,0 +1,134 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-40: Expiration Timestamp +//! +//! <https://github.com/nostr-protocol/nips/blob/master/40.md> + +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt; +use core::num::ParseIntError; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::take_timestamp; +use crate::types::time::Timestamp; + +const EXPIRATION: &str = "expiration"; + +/// NIP-40 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Parse Int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-40 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/40.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip40Tag { + /// Expiration timestamp + Expiration(Timestamp), +} + +impl TagCodec for Nip40Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + EXPIRATION => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::Expiration(timestamp)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + let Self::Expiration(timestamp) = self; + let tag: Vec<String> = vec![String::from(EXPIRATION), timestamp.to_string()]; + Tag::new(tag) + } +} + +impl_tag_codec_conversions!(Nip40Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_empty_tag() { + let tag: Vec<String> = Vec::new(); + let err = Nip40Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::missing_tag_kind())); + } + + #[test] + fn test_non_existing_tag() { + let tag = vec!["hello"]; + let err = Nip40Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Unknown)); + } + + #[test] + fn test_standardized_expiration_tag() { + let raw = 1600000000; + let timestamp = Timestamp::from_secs(raw); + + // Simple + let tag = vec!["expiration".to_string(), raw.to_string()]; + let parsed = Nip40Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip40Tag::Expiration(timestamp)); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + + // Invalid timestamp + let tag = vec!["expiration", "hello"]; + let err = Nip40Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, Error::ParseInt(_))); + + // Missing timestamp + let tag = vec!["expiration"]; + let err = Nip40Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("timestamp"))); + } +} diff --git a/crates/nostr/src/nips/util.rs b/crates/nostr/src/nips/util.rs index 60525423a..4dfc36d0c 100644 --- a/crates/nostr/src/nips/util.rs +++ b/crates/nostr/src/nips/util.rs @@ -1,9 +1,12 @@ use alloc::string::{String, ToString}; +use core::num::ParseIntError; +use core::str::FromStr; use super::nip01::{self, Coordinate}; use crate::event::tag::TagCodecError; use crate::event::{self, EventId}; use crate::key::{self, PublicKey}; +use crate::types::time::Timestamp; use crate::types::url::{self, RelayUrl}; #[inline] @@ -129,3 +132,14 @@ where let relay_url: RelayUrl = RelayUrl::parse(relay_url.as_ref())?; Ok(relay_url) } + +pub(super) fn take_timestamp<T, S, E>(iter: &mut T) -> Result<Timestamp, E> +where + T: Iterator<Item = S>, + S: AsRef<str>, + E: From<ParseIntError> + From<TagCodecError>, +{ + let timestamp: S = iter.next().ok_or(TagCodecError::Missing("timestamp"))?; + let timestamp: Timestamp = Timestamp::from_str(timestamp.as_ref())?; + Ok(timestamp) +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 9632582fd..66471da44 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -49,6 +49,7 @@ pub use crate::nips::nip34::{self, *}; pub use crate::nips::nip35::{self, *}; pub use crate::nips::nip38::{self, *}; pub use crate::nips::nip39::{self, *}; +pub use crate::nips::nip40::{self, *}; pub use crate::nips::nip42::{self, *}; pub use crate::nips::nip44::{self, *}; #[cfg(all(feature = "std", feature = "nip46"))] From c0ed90e91a6cb2fa2a96a07daf9235a2b23727d9 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Fri, 13 Mar 2026 10:27:41 +0100 Subject: [PATCH 07/40] nostr: rework NIP-42 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 4 +- crates/nostr/src/event/tag/list.rs | 10 ++- crates/nostr/src/nips/nip42.rs | 139 +++++++++++++++++++++++++---- 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index dc97439b0..570f52eff 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -756,8 +756,8 @@ impl EventBuilder { S: Into<String>, { Self::new(Kind::Authentication, "").tags([ - Tag::from_standardized(TagStandard::Challenge(challenge.into())), - Tag::from_standardized(TagStandard::Relay(relay)), + Nip42Tag::Challenge(challenge.into()).into(), + Nip42Tag::Relay(relay).into(), ]) } diff --git a/crates/nostr/src/event/tag/list.rs b/crates/nostr/src/event/tag/list.rs index 5ae0680f2..5804c5d86 100644 --- a/crates/nostr/src/event/tag/list.rs +++ b/crates/nostr/src/event/tag/list.rs @@ -24,9 +24,10 @@ use std::sync::OnceLock as OnceCell; use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use super::{Error, Tag, TagCodec}; +use super::{Error, Tag}; use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip40::Nip40Tag; +use crate::nips::nip42::Nip42Tag; use crate::{EventId, PublicKey, SingleLetterTag, TagKind, TagStandard, Timestamp}; /// Tags Indexes @@ -441,8 +442,10 @@ impl Tags { /// Extract NIP42 challenge, if exists. #[inline] pub fn challenge(&self) -> Option<String> { - match self.find_standardized(TagKind::Challenge)? { - TagStandard::Challenge(challenge) => Some(challenge), + let tag: &Tag = self.find(TagKind::Challenge)?; + + match Nip42Tag::try_from(tag) { + Ok(Nip42Tag::Challenge(challenge)) => Some(challenge), _ => None, } } @@ -566,6 +569,7 @@ impl<'de> Deserialize<'de> for Tags { #[cfg(test)] mod tests { use super::*; + use crate::event::tag::TagCodec; use crate::{Event, JsonUtil, RelayUrl}; #[test] diff --git a/crates/nostr/src/nips/nip42.rs b/crates/nostr/src/nips/nip42.rs index c748a23a6..f4fcca786 100644 --- a/crates/nostr/src/nips/nip42.rs +++ b/crates/nostr/src/nips/nip42.rs @@ -6,7 +6,93 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/42.md> -use crate::{Event, Kind, RelayUrl, TagKind, TagStandard}; +use alloc::string::{String, ToString}; +use alloc::vec; +use core::fmt; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::{take_relay_url, take_string}; +use crate::types::url; +use crate::{Event, Kind, RelayUrl}; + +const CHALLENGE: &str = "challenge"; +const RELAY: &str = "relay"; + +/// NIP-42 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-42 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/42.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip42Tag { + /// Authentication challenge + Challenge(String), + /// Relay URL + Relay(RelayUrl), +} + +impl TagCodec for Nip42Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + CHALLENGE => Ok(Self::Challenge(take_string(&mut iter, "challenge")?)), + RELAY => { + let relay_url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + Ok(Self::Relay(relay_url)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Challenge(challenge) => { + Tag::new(vec![String::from(CHALLENGE), challenge.clone()]) + } + Self::Relay(relay) => Tag::new(vec![String::from(RELAY), relay.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip42Tag); /// Check if the [`Event`] is a valid authentication. /// @@ -23,23 +109,23 @@ pub fn is_valid_auth_event(event: &Event, relay_url: &RelayUrl, challenge: &str) } // Check if it has "relay" tag - match event.tags.find_standardized(TagKind::Relay) { - Some(TagStandard::Relay(url)) => { - if &url != relay_url { - return false; - } - } - Some(..) | None => return false, + let relay_matches: bool = event.tags.iter().any(|tag| match Nip42Tag::try_from(tag) { + Ok(Nip42Tag::Relay(url)) => &url == relay_url, + _ => false, + }); + + if !relay_matches { + return false; } // Check if it has the challenge - match event.tags.find_standardized(TagKind::Challenge) { - Some(TagStandard::Challenge(c)) => { - if c != challenge { - return false; - } - } - Some(..) | None => return false, + let challenge_matches: bool = event.tags.iter().any(|tag| match Nip42Tag::try_from(tag) { + Ok(Nip42Tag::Challenge(value)) => value == challenge, + _ => false, + }); + + if !challenge_matches { + return false; } // Valid @@ -51,6 +137,29 @@ mod tests { use super::*; use crate::{EventBuilder, Keys}; + #[test] + fn test_standardized_challenge_tag() { + let tag = vec!["challenge".to_string(), "1234567890".to_string()]; + let parsed = Nip42Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip42Tag::Challenge(String::from("1234567890"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_relay_tag() { + let relay = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let tag = vec!["relay".to_string(), relay.to_string()]; + let parsed = Nip42Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip42Tag::Relay(relay.clone())); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + assert_eq!( + Nip42Tag::try_from(Tag::parse(["relay", "wss://relay.damus.io"]).unwrap()).unwrap(), + Nip42Tag::Relay(relay) + ); + } + #[test] fn test_valid_auth_event() { let keys = Keys::generate(); From ea40f5f359ca7519c8acb36d884a0592cb4bfeee Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Fri, 13 Mar 2026 10:51:27 +0100 Subject: [PATCH 08/40] nostr: rework NIP-65 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 10 +- crates/nostr/src/event/tag/error.rs | 11 +- crates/nostr/src/event/tag/mod.rs | 12 -- crates/nostr/src/event/tag/standard.rs | 95 +--------------- crates/nostr/src/nips/nip65.rs | 151 +++++++++++++++++++++++-- 5 files changed, 153 insertions(+), 126 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 570f52eff..f17969e10 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -370,9 +370,13 @@ impl EventBuilder { where I: IntoIterator<Item = (RelayUrl, Option<RelayMetadata>)>, { - let tags = iter - .into_iter() - .map(|(url, metadata)| Tag::relay_metadata(url, metadata)); + let tags = iter.into_iter().map(|(relay_url, metadata)| { + Nip65Tag::RelayMetadata { + relay_url, + metadata, + } + .to_tag() + }); Self::new(Kind::RelayList, "").tags(tags) } diff --git a/crates/nostr/src/event/tag/error.rs b/crates/nostr/src/event/tag/error.rs index 7de9a495c..eab2225af 100644 --- a/crates/nostr/src/event/tag/error.rs +++ b/crates/nostr/src/event/tag/error.rs @@ -9,7 +9,7 @@ use hashes::hex::HexToArrayError; #[cfg(feature = "nip98")] use crate::nips::nip98; -use crate::nips::{nip01, nip10, nip39, nip53, nip65, nip88}; +use crate::nips::{nip01, nip10, nip39, nip53, nip88}; use crate::types::image; use crate::types::url::{Error as RelayUrlError, ParseError}; use crate::{key, secp256k1}; @@ -38,8 +38,6 @@ pub enum Error { NIP39(nip39::Error), /// NIP53 error NIP53(nip53::Error), - /// NIP65 error - NIP65(nip65::Error), /// NIP88 error NIP88(nip88::Error), /// NIP98 error @@ -72,7 +70,6 @@ impl fmt::Display for Error { Self::NIP10(e) => e.fmt(f), Self::NIP39(e) => e.fmt(f), Self::NIP53(e) => e.fmt(f), - Self::NIP65(e) => e.fmt(f), Self::NIP88(e) => e.fmt(f), #[cfg(feature = "nip98")] Self::NIP98(e) => e.fmt(f), @@ -145,12 +142,6 @@ impl From<nip53::Error> for Error { } } -impl From<nip65::Error> for Error { - fn from(e: nip65::Error) -> Self { - Self::NIP65(e) - } -} - impl From<nip88::Error> for Error { fn from(e: nip88::Error) -> Self { Self::NIP88(e) diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 6cd6f3a23..6fdd3b0cf 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -33,7 +33,6 @@ use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip10::Marker; use crate::nips::nip40::Nip40Tag; use crate::nips::nip56::Report; -use crate::nips::nip65::RelayMetadata; use crate::types::Url; use crate::{ImageDimensions, PublicKey, RelayUrl, SingleLetterTag, Timestamp}; @@ -339,17 +338,6 @@ impl Tag { Self::from_standardized(TagStandard::PublicKeyReport(public_key, report)) } - /// Compose `["r", "<relay-url>", "<metadata>"]` tag - /// - /// <https://github.com/nostr-protocol/nips/blob/master/65.md> - #[inline] - pub fn relay_metadata(relay_url: RelayUrl, metadata: Option<RelayMetadata>) -> Self { - Self::from_standardized(TagStandard::RelayMetadata { - relay_url, - metadata, - }) - } - /// Relay url /// /// JSON: `["relay", "<relay-url>"]` diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 25dc743e3..baecdf7d8 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -22,7 +22,6 @@ use crate::nips::nip39::Identity; use crate::nips::nip48::Protocol; use crate::nips::nip53::{LiveEventMarker, LiveEventStatus}; use crate::nips::nip56::Report; -use crate::nips::nip65::RelayMetadata; use crate::nips::nip73::{ExternalContentId, Nip73Kind}; use crate::nips::nip88::{self, PollOption, PollType}; use crate::nips::nip90::DataVendingMachineStatus; @@ -121,13 +120,6 @@ pub enum TagStandard { proof: Option<Signature>, }, Reference(String), - /// Relay Metadata - /// - /// <https://github.com/nostr-protocol/nips/blob/master/65.md> - RelayMetadata { - relay_url: RelayUrl, - metadata: Option<RelayMetadata>, - }, Hashtag(String), Geohash(String), /// External Content ID @@ -631,12 +623,10 @@ impl TagStandard { uppercase: false, }) } - Self::Reference(..) | Self::RelayMetadata { .. } => { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::R, - uppercase: false, - }) - } + Self::Reference(..) => TagKind::SingleLetter(SingleLetterTag { + character: Alphabet::R, + uppercase: false, + }), Self::Hashtag(..) => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::T, uppercase: false, @@ -869,16 +859,6 @@ impl From<TagStandard> for Vec<String> { tag } TagStandard::Reference(r) => vec![tag_kind, r], - TagStandard::RelayMetadata { - relay_url, - metadata, - } => { - let mut tag = vec![tag_kind, relay_url.to_string()]; - if let Some(metadata) = metadata { - tag.push(metadata.to_string()); - } - tag - } TagStandard::Hashtag(t) => vec![tag_kind, t], TagStandard::Geohash(g) => vec![tag_kind, g], TagStandard::Coordinate { @@ -1307,12 +1287,7 @@ where let tag_1: &str = tag[1].as_ref(); let tag_2: &str = tag[2].as_ref(); - return if tag_1.starts_with("ws://") || tag_1.starts_with("wss://") { - Ok(TagStandard::RelayMetadata { - relay_url: RelayUrl::parse(tag_1)?, - metadata: Some(RelayMetadata::from_str(tag_2)?), - }) - } else if tag_2 == EUC { + return if tag_2 == EUC { // ["r", "<commit-id>", "euc"] let commit: Sha1Hash = Sha1Hash::from_str(tag_1)?; Ok(TagStandard::GitEarliestUniqueCommitId(commit)) @@ -1324,14 +1299,7 @@ where if tag.len() >= 2 && !uppercase { let tag_1: &str = tag[1].as_ref(); - return if tag_1.starts_with("ws://") || tag_1.starts_with("wss://") { - Ok(TagStandard::RelayMetadata { - relay_url: RelayUrl::parse(tag_1)?, - metadata: None, - }) - } else { - Ok(TagStandard::Reference(tag_1.to_string())) - }; + return Ok(TagStandard::Reference(tag_1.to_string())); } Err(Error::UnknownStandardizedTag) @@ -2143,33 +2111,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec!["r", "wss://atlas.nostr.land/"], - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land/").unwrap(), - metadata: None - } - .to_vec() - ); - - assert_eq!( - vec!["r", "wss://atlas.nostr.land/", "read"], - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land/").unwrap(), - metadata: Some(RelayMetadata::Read) - } - .to_vec() - ); - - assert_eq!( - vec!["r", "wss://atlas.nostr.land", "write"], - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land").unwrap(), - metadata: Some(RelayMetadata::Write) - } - .to_vec() - ); - assert_eq!( vec!["r", "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", "euc"], TagStandard::GitEarliestUniqueCommitId( @@ -2667,30 +2608,6 @@ mod tests { } ); - assert_eq!( - TagStandard::parse(&["r", "wss://atlas.nostr.land/"]).unwrap(), - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land/").unwrap(), - metadata: None - } - ); - - assert_eq!( - TagStandard::parse(&["r", "wss://atlas.nostr.land", "read"]).unwrap(), - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land").unwrap(), - metadata: Some(RelayMetadata::Read) - } - ); - - assert_eq!( - TagStandard::parse(&["r", "wss://atlas.nostr.land", "write"]).unwrap(), - TagStandard::RelayMetadata { - relay_url: RelayUrl::parse("wss://atlas.nostr.land").unwrap(), - metadata: Some(RelayMetadata::Write) - } - ); - assert_eq!( TagStandard::parse(&[ "e", diff --git a/crates/nostr/src/nips/nip65.rs b/crates/nostr/src/nips/nip65.rs index e74db23c8..1b0767b11 100644 --- a/crates/nostr/src/nips/nip65.rs +++ b/crates/nostr/src/nips/nip65.rs @@ -6,14 +6,25 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/65.md> +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use core::fmt; use core::str::FromStr; -use crate::{Event, RelayUrl, TagStandard}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::take_relay_url; +use crate::types::url; +use crate::{Event, RelayUrl}; + +const RELAY_METADATA: &str = "r"; /// NIP56 error #[derive(Debug, PartialEq, Eq)] pub enum Error { + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), /// Invalid Relay Metadata InvalidRelayMetadata, } @@ -23,11 +34,25 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidRelayMetadata => f.write_str("Invalid relay metadata"), } } } +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Relay Metadata #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum RelayMetadata { @@ -77,20 +102,122 @@ impl FromStr for RelayMetadata { } } +/// Standardized NIP-65 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/65.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip65Tag { + /// Relay metadata + RelayMetadata { + /// Relay URL + relay_url: RelayUrl, + /// Relay metadata + metadata: Option<RelayMetadata>, + }, +} + +impl TagCodec for Nip65Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + RELAY_METADATA => { + let relay_url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + + let metadata: Option<RelayMetadata> = match iter.next() { + Some(metadata) => Some(RelayMetadata::from_str(metadata.as_ref())?), + None => None, + }; + + Ok(Self::RelayMetadata { + relay_url, + metadata, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::RelayMetadata { + relay_url, + metadata, + } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + metadata.is_some() as usize); + + tag.push(String::from(RELAY_METADATA)); + tag.push(relay_url.to_string()); + + if let Some(metadata) = metadata { + tag.push(metadata.to_string()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip65Tag); + /// Extracts the relay info (url, optional read/write flag) from the event #[inline] pub fn extract_relay_list( event: &Event, ) -> impl Iterator<Item = (RelayUrl, Option<RelayMetadata>)> + '_ { - event.tags.iter().filter_map(|tag| { - if let Some(TagStandard::RelayMetadata { - relay_url, - metadata, - }) = tag.standardized() - { - Some((relay_url, metadata)) - } else { - None - } - }) + event + .tags + .iter() + .filter_map(|tag| match Nip65Tag::try_from(tag) { + Ok(Nip65Tag::RelayMetadata { + relay_url, + metadata, + }) => Some((relay_url, metadata)), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_standardized_relay_metadata_tag() { + let relay_url = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let tag = vec!["r".to_string(), relay_url.to_string(), String::from("read")]; + let parsed = Nip65Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip65Tag::RelayMetadata { + relay_url: relay_url.clone(), + metadata: Some(RelayMetadata::Read), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_relay_metadata_tag_without_marker() { + let relay_url = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let tag = vec!["r".to_string(), relay_url.to_string()]; + let parsed = Nip65Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip65Tag::RelayMetadata { + relay_url: relay_url.clone(), + metadata: None, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } } From 519f0e37cbfff713546db05c9aea0a18be241be7 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 17 Mar 2026 15:31:02 +0100 Subject: [PATCH 09/40] nostr: rework NIP-22 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 15 +- crates/nostr/src/nips/nip01/tags.rs | 115 +++-- crates/nostr/src/nips/nip22.rs | 703 +++++++++++++++++++++++----- 3 files changed, 653 insertions(+), 180 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index f17969e10..0dc83b6d4 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1856,17 +1856,18 @@ impl EventBuilder { S: Into<String>, { let tags = vec![ - Tag::from_standardized(TagStandard::Event { - event_id: reply_to.id, - relay_url, - marker: None, + Nip22Tag::Event { + id: reply_to.id, + relay_hint: relay_url, public_key: Some(reply_to.pubkey), uppercase: true, - }), - Tag::from_standardized(TagStandard::Kind { + } + .to_tag(), + Nip22Tag::Kind { kind: Kind::Thread, uppercase: true, - }), + } + .to_tag(), ]; Self::new(Kind::Comment, content).tags(tags) diff --git a/crates/nostr/src/nips/nip01/tags.rs b/crates/nostr/src/nips/nip01/tags.rs index c2129fd8f..1d3ea80c7 100644 --- a/crates/nostr/src/nips/nip01/tags.rs +++ b/crates/nostr/src/nips/nip01/tags.rs @@ -90,67 +90,28 @@ impl TagCodec for Nip01Tag { Self::Coordinate { coordinate, relay_hint, - } => { - let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); - - tag.push(String::from("a")); - tag.push(coordinate.to_string()); - - if let Some(relay_hint) = relay_hint { - tag.push(relay_hint.to_string()); - } - - Tag::new(tag) - } + } => serialize_a_tag(coordinate, relay_hint.as_ref()), Self::Event { id, relay_hint, public_key, - } => { - let mut tag: Vec<String> = Vec::with_capacity( - 2 + relay_hint.is_some() as usize + public_key.is_some() as usize, - ); - - tag.push(String::from("e")); - tag.push(id.to_hex()); - - if let Some(relay_hint) = relay_hint { - tag.push(relay_hint.to_string()); - } else if public_key.is_some() { - tag.push(String::new()); - } - - if let Some(public_key) = public_key { - tag.push(public_key.to_string()); - } - - Tag::new(tag) - } + } => serialize_e_tag(id, relay_hint.as_ref(), public_key.as_ref()), Self::Identifier(identifier) => { Tag::new(vec![String::from("d"), identifier.to_string()]) } Self::PublicKey { public_key, relay_hint, - } => { - let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); - - tag.push(String::from("p")); - tag.push(public_key.to_hex()); - - if let Some(relay_hint) = relay_hint { - tag.push(relay_hint.to_string()); - } - - Tag::new(tag) - } + } => serialize_p_tag(public_key, relay_hint.as_ref()), } } } impl_tag_codec_conversions!(Nip01Tag); -fn parse_a_tag<T, S>(mut iter: T) -> Result<(Coordinate, Option<RelayUrl>), Error> +pub(in super::super) fn parse_a_tag<T, S>( + mut iter: T, +) -> Result<(Coordinate, Option<RelayUrl>), Error> where T: Iterator<Item = S>, S: AsRef<str>, @@ -161,7 +122,25 @@ where Ok((coordinate, relay_hint)) } -fn parse_e_tag<T, S>(mut iter: T) -> Result<(EventId, Option<RelayUrl>, Option<PublicKey>), Error> +pub(in super::super) fn serialize_a_tag( + coordinate: &Coordinate, + relay_hint: Option<&RelayUrl>, +) -> Tag { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + + tag.push(String::from("a")); + tag.push(coordinate.to_string()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) +} + +pub(in super::super) fn parse_e_tag<T, S>( + mut iter: T, +) -> Result<(EventId, Option<RelayUrl>, Option<PublicKey>), Error> where T: Iterator<Item = S>, S: AsRef<str>, @@ -173,7 +152,33 @@ where Ok((id, relay_hint, public_key)) } -fn parse_p_tag<T, S>(mut iter: T) -> Result<(PublicKey, Option<RelayUrl>), Error> +pub(in super::super) fn serialize_e_tag( + id: &EventId, + relay_hint: Option<&RelayUrl>, + public_key: Option<&PublicKey>, +) -> Tag { + let mut tag: Vec<String> = + Vec::with_capacity(2 + relay_hint.is_some() as usize + public_key.is_some() as usize); + + tag.push(String::from("e")); + tag.push(id.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } else if public_key.is_some() { + tag.push(String::new()); + } + + if let Some(public_key) = public_key { + tag.push(public_key.to_string()); + } + + Tag::new(tag) +} + +pub(in super::super) fn parse_p_tag<T, S>( + mut iter: T, +) -> Result<(PublicKey, Option<RelayUrl>), Error> where T: Iterator<Item = S>, S: AsRef<str>, @@ -184,6 +189,22 @@ where Ok((public_key, relay_hint)) } +pub(in super::super) fn serialize_p_tag( + public_key: &PublicKey, + relay_hint: Option<&RelayUrl>, +) -> Tag { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + + tag.push(String::from("p")); + tag.push(public_key.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/nostr/src/nips/nip22.rs b/crates/nostr/src/nips/nip22.rs index ce05c4965..af09d1006 100644 --- a/crates/nostr/src/nips/nip22.rs +++ b/crates/nostr/src/nips/nip22.rs @@ -7,11 +7,269 @@ //! <https://github.com/nostr-protocol/nips/blob/master/22.md> use alloc::borrow::Cow; +use alloc::string::{String, ToString}; use alloc::vec::Vec; +use core::fmt; +use core::str::FromStr; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::nip01::{self, Coordinate}; +use crate::nips::nip73::{self, ExternalContentId, Nip73Kind}; +use crate::types::url; +use crate::{Event, EventId, Kind, PublicKey, RelayUrl, Url, event, key}; + +/// NIP-22 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Event error + Event(event::Error), + /// Keys error + Keys(key::Error), + /// NIP-01 error + Nip01(nip01::Error), + /// NIP-73 error + Nip73(nip73::Error), + /// Relay URL error + RelayUrl(url::Error), + /// URL error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Event(e) => e.fmt(f), + Self::Keys(e) => e.fmt(f), + Self::Nip01(e) => e.fmt(f), + Self::Nip73(e) => e.fmt(f), + Self::RelayUrl(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::Nip01(e) + } +} + +impl From<nip73::Error> for Error { + fn from(e: nip73::Error) -> Self { + Self::Nip73(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::RelayUrl(e) + } +} -use crate::nips::nip01::Coordinate; -use crate::nips::nip73::ExternalContentId; -use crate::{Alphabet, Event, EventId, Kind, PublicKey, RelayUrl, Tag, TagKind, TagStandard, Url}; +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-22 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/22.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip22Tag { + /// `a` and `A` tags + Coordinate { + /// Coordinate + coordinate: Coordinate, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Uppercase variant + uppercase: bool, + }, + /// `e` and `E` tags + Event { + /// Event ID + id: EventId, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Public key hint + public_key: Option<PublicKey>, + /// Uppercase variant + uppercase: bool, + }, + /// `i` and `I` tags + ExternalContent { + /// External content + content: ExternalContentId, + /// Optional URL hint + hint: Option<Url>, + /// Uppercase variant + uppercase: bool, + }, + /// Numeric `k` and `K` tags + Kind { + /// Event kind + kind: Kind, + /// Uppercase variant + uppercase: bool, + }, + /// NIP-73 `k` and `K` tags + Nip73Kind { + /// NIP-73 kind + kind: Nip73Kind, + /// Uppercase variant + uppercase: bool, + }, + /// `p` and `P` tags + PublicKey { + /// Public key + public_key: PublicKey, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Uppercase variant + uppercase: bool, + }, +} + +impl TagCodec for Nip22Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + "a" => parse_a_tag(iter, false), + "A" => parse_a_tag(iter, true), + "e" => parse_e_tag(iter, false), + "E" => parse_e_tag(iter, true), + "i" => parse_i_tag(iter, false), + "I" => parse_i_tag(iter, true), + "k" => parse_k_tag(iter, false), + "K" => parse_k_tag(iter, true), + "p" => parse_p_tag(iter, false), + "P" => parse_p_tag(iter, true), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Coordinate { + coordinate, + relay_hint, + uppercase, + } => { + // Serialize as lowercase "a" tag + let mut tag: Tag = nip01::serialize_a_tag(coordinate, relay_hint.as_ref()); + + // Replace the "a" tag with the "A" tag, if uppercase. + if *uppercase { + tag[0] = String::from("A"); + } + + tag + } + Self::Event { + id, + relay_hint, + public_key, + uppercase, + } => { + // Serialize as lowercase "e" tag + let mut tag: Tag = + nip01::serialize_e_tag(id, relay_hint.as_ref(), public_key.as_ref()); + + // Replace the "e" tag with the "E" tag, if uppercase. + if *uppercase { + tag[0] = String::from("E"); + } + + tag + } + Self::ExternalContent { + content, + hint, + uppercase, + } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + hint.is_some() as usize); + + tag.push(if *uppercase { + String::from("I") + } else { + String::from("i") + }); + tag.push(content.to_string()); + + if let Some(hint) = hint { + tag.push(hint.to_string()); + } + + Tag::new(tag) + } + Self::Kind { kind, uppercase } => Tag::new(vec![ + if *uppercase { + String::from("K") + } else { + String::from("k") + }, + kind.to_string(), + ]), + Self::Nip73Kind { kind, uppercase } => Tag::new(vec![ + if *uppercase { + String::from("K") + } else { + String::from("k") + }, + kind.to_string(), + ]), + Self::PublicKey { + public_key, + relay_hint, + uppercase, + } => { + // Serialize as lowercase "p" tag + let mut tag: Tag = nip01::serialize_p_tag(public_key, relay_hint.as_ref()); + + // Replace the "p" tag with the "P" tag, if uppercase. + if *uppercase { + tag[0] = String::from("P"); + } + + tag + } + } + } +} + +impl_tag_codec_conversions!(Nip22Tag); /// Comment target #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -128,27 +386,35 @@ impl<'a> CommentTarget<'a> { tags.reserve_exact( 1 + usize::from(pubkey_hint.is_some()) + usize::from(kind.is_some()), ); - tags.push(Tag::from_standardized(TagStandard::Event { - event_id: *id, - relay_url: relay_hint.clone().map(|r| r.into_owned()), - marker: None, - public_key: pubkey_hint.as_ref().copied(), - uppercase: is_root, - })); + tags.push( + Nip22Tag::Event { + id: *id, + relay_hint: relay_hint.clone().map(|r| r.into_owned()), + public_key: pubkey_hint.as_ref().copied(), + uppercase: is_root, + } + .to_tag(), + ); if let Some(pubkey) = pubkey_hint { - tags.push(Tag::from_standardized(TagStandard::PublicKey { - public_key: *pubkey, - relay_url: relay_hint.clone().map(|r| r.into_owned()), - uppercase: is_root, - })); + tags.push( + Nip22Tag::PublicKey { + public_key: *pubkey, + relay_hint: relay_hint.clone().map(|r| r.into_owned()), + uppercase: is_root, + } + .to_tag(), + ); } if let Some(kind) = kind { - tags.push(Tag::from_standardized(TagStandard::Kind { - kind: *kind, - uppercase: is_root, - })); + tags.push( + Nip22Tag::Kind { + kind: *kind, + uppercase: is_root, + } + .to_tag(), + ); } } Self::Coordinate { @@ -160,32 +426,47 @@ impl<'a> CommentTarget<'a> { let kind: Kind = address.kind; tags.reserve_exact(3); - tags.push(Tag::from_standardized(TagStandard::Coordinate { - coordinate: address.clone().into_owned(), - relay_url: relay_hint.clone().map(|r| r.into_owned()), - uppercase: is_root, - })); - tags.push(Tag::from_standardized(TagStandard::PublicKey { - public_key, - relay_url: relay_hint.clone().map(|r| r.into_owned()), - uppercase: is_root, - })); - tags.push(Tag::from_standardized(TagStandard::Kind { - kind, - uppercase: is_root, - })); + tags.push( + Nip22Tag::Coordinate { + coordinate: address.clone().into_owned(), + relay_hint: relay_hint.clone().map(|r| r.into_owned()), + uppercase: is_root, + } + .to_tag(), + ); + tags.push( + Nip22Tag::PublicKey { + public_key, + relay_hint: relay_hint.clone().map(|r| r.into_owned()), + uppercase: is_root, + } + .to_tag(), + ); + tags.push( + Nip22Tag::Kind { + kind, + uppercase: is_root, + } + .to_tag(), + ); } Self::External { content, hint } => { tags.reserve_exact(2); - tags.push(Tag::from_standardized(TagStandard::ExternalContent { - content: ExternalContentId::clone(content), - hint: hint.clone().map(|r| r.into_owned()), - uppercase: is_root, - })); - tags.push(Tag::from_standardized(TagStandard::Nip73Kind { - kind: content.kind(), - uppercase: is_root, - })) + tags.push( + Nip22Tag::ExternalContent { + content: ExternalContentId::clone(content), + hint: hint.clone().map(|r| r.into_owned()), + uppercase: is_root, + } + .to_tag(), + ); + tags.push( + Nip22Tag::Nip73Kind { + kind: content.kind(), + uppercase: is_root, + } + .to_tag(), + ) } } @@ -220,25 +501,23 @@ fn extract_data(event: &Event, is_root: bool) -> Option<CommentTarget<'_>> { // Try to extract event if let Some((event_id, relay_hint, public_key)) = extract_event(event, is_root) { + let kind: Kind = extract_kind(event, is_root)?; + return Some(CommentTarget::Event { id: event_id, relay_hint: relay_hint.map(Cow::Owned), pubkey_hint: public_key, - kind: extract_kind(event, is_root), + kind: Some(kind), }); } // Try to extract coordinate if let Some((address, relay_hint)) = extract_coordinate(event, is_root) { - // Extract kind - // TODO: for now we allow optional `k`/`K` tag, but according to NIP-22, it should be mandatory. - let kind: Option<Kind> = extract_kind(event, is_root); + let kind: Kind = extract_kind(event, is_root)?; // Check if matches the address kind - if let Some(kind) = kind { - if kind != address.kind { - return None; - } + if kind != address.kind { + return None; } return Some(CommentTarget::Coordinate { @@ -248,6 +527,12 @@ fn extract_data(event: &Event, is_root: bool) -> Option<CommentTarget<'_>> { } if let Some((content, hint)) = extract_external(event, is_root) { + let kind: Nip73Kind = extract_nip73_kind(event, is_root)?; + + if kind != content.kind() { + return None; + } + return Some(CommentTarget::External { content: Cow::Owned(content), hint: hint.map(Cow::Owned), @@ -273,9 +558,20 @@ fn check_return<T>(val: T, is_root: bool, uppercase: bool) -> Option<T> { fn extract_kind(event: &Event, is_root: bool) -> Option<Kind> { event .tags - .filter_standardized(TagKind::single_letter(Alphabet::K, is_root)) - .find_map(|tag| match tag { - TagStandard::Kind { kind, uppercase } => check_return(kind, is_root, uppercase), + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::Kind { kind, uppercase }) => check_return(kind, is_root, uppercase), + _ => None, + }) +} + +/// Returns the first NIP-73 kind tag that matches the `is_root` condition. +fn extract_nip73_kind(event: &Event, is_root: bool) -> Option<Nip73Kind> { + event + .tags + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::Nip73Kind { kind, uppercase }) => check_return(kind, is_root, uppercase), _ => None, }) } @@ -291,15 +587,14 @@ fn extract_event( ) -> Option<(EventId, Option<RelayUrl>, Option<PublicKey>)> { event .tags - .filter_standardized(TagKind::single_letter(Alphabet::E, is_root)) - .find_map(|tag| match tag { - TagStandard::Event { - event_id, - relay_url, + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::Event { + id, + relay_hint, public_key, uppercase, - .. - } => check_return((event_id, relay_url, public_key), is_root, uppercase), + }) => check_return((id, relay_hint, public_key), is_root, uppercase), _ => None, }) } @@ -312,14 +607,13 @@ fn extract_event( fn extract_coordinate(event: &Event, is_root: bool) -> Option<(Coordinate, Option<RelayUrl>)> { event .tags - .filter_standardized(TagKind::single_letter(Alphabet::A, is_root)) - .find_map(|tag| match tag { - TagStandard::Coordinate { + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::Coordinate { coordinate, - relay_url, + relay_hint, uppercase, - .. - } => check_return((coordinate, relay_url), is_root, uppercase), + }) => check_return((coordinate, relay_hint), is_root, uppercase), _ => None, }) } @@ -332,46 +626,196 @@ fn extract_coordinate(event: &Event, is_root: bool) -> Option<(Coordinate, Optio fn extract_external(event: &Event, is_root: bool) -> Option<(ExternalContentId, Option<Url>)> { event .tags - .filter_standardized(TagKind::single_letter(Alphabet::I, is_root)) - .find_map(|tag| match tag { - TagStandard::ExternalContent { + .iter() + .find_map(|tag| match Nip22Tag::try_from(tag) { + Ok(Nip22Tag::ExternalContent { content, hint, uppercase, - } => check_return((content, hint), is_root, uppercase), + }) => check_return((content, hint), is_root, uppercase), _ => None, }) } +fn parse_a_tag<T, S>(iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + // At the moment the NIP-22 "a" tag is the same as the NIP-01, but with possibility of uppercasing. + let (coordinate, relay_hint) = nip01::parse_a_tag(iter)?; + + Ok(Nip22Tag::Coordinate { + coordinate, + relay_hint, + uppercase, + }) +} + +fn parse_e_tag<T, S>(iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + // At the moment the NIP-22 "e" tag is the same as the NIP-01, but with possibility of uppercasing. + let (id, relay_hint, public_key) = nip01::parse_e_tag(iter)?; + + Ok(Nip22Tag::Event { + id, + relay_hint, + public_key, + uppercase, + }) +} + +fn parse_i_tag<T, S>(mut iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let content: S = iter.next().ok_or(Error::MissingExternalContent)?; + let content: ExternalContentId = ExternalContentId::from_str(content.as_ref())?; + + let hint: Option<Url> = match iter.next() { + Some(hint) => Some(Url::parse(hint.as_ref())?), + None => None, + }; + + Ok(Nip22Tag::ExternalContent { + content, + hint, + uppercase, + }) +} + +fn parse_k_tag<T, S>(mut iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let kind: S = iter.next().ok_or(TagCodecError::Missing("kind"))?; + + if let Ok(kind_number) = u16::from_str(kind.as_ref()) { + Ok(Nip22Tag::Kind { + kind: Kind::from_u16(kind_number), + uppercase, + }) + } else { + Ok(Nip22Tag::Nip73Kind { + kind: Nip73Kind::from_str(kind.as_ref())?, + uppercase, + }) + } +} + +fn parse_p_tag<T, S>(iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + // At the moment the NIP-22 "p" tag is the same as the NIP-01, but with possibility of uppercasing. + let (public_key, relay_hint) = nip01::parse_p_tag(iter)?; + + Ok(Nip22Tag::PublicKey { + public_key, + relay_hint, + uppercase, + }) +} + #[cfg(all(test, feature = "std", feature = "os-rng"))] mod tests { use super::*; use crate::prelude::*; fn check_kind(tags: &[Tag], kind: Kind, uppercase: bool) { - assert!(tags.contains(&Tag::from_standardized(TagStandard::Kind { - kind, - uppercase - }))); + assert!(tags.contains(&Tag::from(Nip22Tag::Kind { kind, uppercase }))); } fn check_nip73_kind(tags: &[Tag], kind: Nip73Kind, uppercase: bool) { - assert!( - tags.contains(&Tag::from_standardized(TagStandard::Nip73Kind { - kind, - uppercase - })) - ); + assert!(tags.contains(&Tag::from(Nip22Tag::Nip73Kind { kind, uppercase }))); } fn check_pubkey(tags: &[Tag], public_key: PublicKey, uppercase: bool) { - assert!( - tags.contains(&Tag::from_standardized(TagStandard::PublicKey { - public_key, - relay_url: None, - uppercase - })) + assert!(tags.contains(&Tag::from(Nip22Tag::PublicKey { + public_key, + relay_hint: None, + uppercase, + }))); + } + + #[test] + fn test_standardized_event_tag() { + let keys = Keys::generate(); + let kind = Kind::GitPatch; + let id = EventId::new( + &keys.public_key(), + &Timestamp::from_secs(1), + &kind, + &Tags::new(), + "", + ); + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("E"), + id.to_hex(), + relay_hint.to_string(), + keys.public_key().to_string(), + ]; + let parsed = Nip22Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip22Tag::Event { + id, + relay_hint: Some(relay_hint), + public_key: Some(keys.public_key()), + uppercase: true, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_event_tag_with_empty_relay_hint() { + let keys = Keys::generate(); + let id = EventId::all_zeros(); + let tag = vec![ + String::from("E"), + id.to_hex(), + String::new(), + keys.public_key().to_string(), + ]; + let parsed = Nip22Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip22Tag::Event { + id, + relay_hint: None, + public_key: Some(keys.public_key()), + uppercase: true, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_external_content_tag() { + let content = ExternalContentId::Url(Url::parse("https://rust-nostr.org").unwrap()); + let hint = Url::parse("https://example.com").unwrap(); + let tag = vec![String::from("I"), content.to_string(), hint.to_string()]; + let parsed = Nip22Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip22Tag::ExternalContent { + content, + hint: Some(hint), + uppercase: true, + } ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); } #[test] @@ -390,33 +834,42 @@ mod tests { // Root let root_vec = comment_target.as_vec(true); - assert!( - root_vec.contains(&Tag::from_standardized(TagStandard::Event { - event_id, - relay_url: None, - marker: None, - public_key: Some(keys.public_key()), - uppercase: true - })) - ); + assert!(root_vec.contains(&Tag::from(Nip22Tag::Event { + id: event_id, + relay_hint: None, + public_key: Some(keys.public_key()), + uppercase: true, + }))); check_pubkey(&root_vec, keys.public_key(), true); check_kind(&root_vec, kind, true); // Parent let parent_vec = comment_target.as_vec(false); - assert!( - parent_vec.contains(&Tag::from_standardized(TagStandard::Event { - event_id, - relay_url: None, - marker: None, - public_key: Some(keys.public_key()), - uppercase: false - })) - ); + assert!(parent_vec.contains(&Tag::from(Nip22Tag::Event { + id: event_id, + relay_hint: None, + public_key: Some(keys.public_key()), + uppercase: false, + }))); check_pubkey(&parent_vec, keys.public_key(), false); check_kind(&parent_vec, kind, false); } + #[test] + fn test_invalid_event_tag_pubkey() { + let event_id = EventId::all_zeros(); + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("E"), + event_id.to_hex(), + relay_hint.to_string(), + String::from("not-a-pubkey"), + ]; + + let err = Nip22Tag::parse(&tag).unwrap_err(); + assert!(matches!(err, super::Error::Nip01(nip01::Error::Keys(_)))); + } + #[test] fn test_coordinate() { let keys = Keys::generate(); @@ -427,19 +880,21 @@ mod tests { // Root let root_vec = comment_target.as_vec(true); - assert!( - root_vec.contains(&Tag::from_standardized(TagStandard::Coordinate { - coordinate: coordinate.clone(), - relay_url: None, - uppercase: true - })) - ); + assert!(root_vec.contains(&Tag::from(Nip22Tag::Coordinate { + coordinate: coordinate.clone(), + relay_hint: None, + uppercase: true, + }))); check_pubkey(&root_vec, keys.public_key(), true); check_kind(&root_vec, kind, true); // Parent let parent_vec = comment_target.as_vec(false); - assert!(parent_vec.contains(&Tag::coordinate(coordinate, None))); + assert!(parent_vec.contains(&Tag::from(Nip22Tag::Coordinate { + coordinate, + relay_hint: None, + uppercase: false, + }))); check_pubkey(&parent_vec, keys.public_key(), false); check_kind(&parent_vec, kind, false); } @@ -453,24 +908,20 @@ mod tests { // Root let root_vec = comment_target.as_vec(true); - assert!( - root_vec.contains(&Tag::from_standardized(TagStandard::ExternalContent { - content: external_content.clone(), - hint: None, - uppercase: true - })) - ); + assert!(root_vec.contains(&Tag::from(Nip22Tag::ExternalContent { + content: external_content.clone(), + hint: None, + uppercase: true, + }))); check_nip73_kind(&root_vec, kind.clone(), true); // Parent let parent_vec = comment_target.as_vec(false); - assert!( - parent_vec.contains(&Tag::from_standardized(TagStandard::ExternalContent { - content: external_content.clone(), - hint: None, - uppercase: false - })) - ); + assert!(parent_vec.contains(&Tag::from(Nip22Tag::ExternalContent { + content: external_content.clone(), + hint: None, + uppercase: false, + }))); check_nip73_kind(&parent_vec, kind, false); } } From d3b2b85f7e11dace6c40d81d507580d02578a954 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 20 Apr 2026 11:05:47 +0200 Subject: [PATCH 10/40] nostr: rework NIP-34 tags Closes https://github.com/rust-nostr/nostr/issues/1204 Closes https://github.com/rust-nostr/nostr/issues/1216 Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/mod.rs | 13 - crates/nostr/src/event/tag/standard.rs | 172 ------- crates/nostr/src/nips/nip34.rs | 672 ++++++++++++++++++++++--- 3 files changed, 592 insertions(+), 265 deletions(-) diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 6fdd3b0cf..1aa99df4d 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -367,19 +367,6 @@ impl Tag { Self::from_standardized(TagStandard::AllRelays) } - /// Repository head - /// - /// JSON: `["HEAD", "ref: refs/heads/<branch-name>"]` - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - #[inline] - pub fn head<S>(branch_name: S) -> Self - where - S: Into<String>, - { - Self::from_standardized(TagStandard::GitHead(branch_name.into())) - } - /// Compose `["t", "<hashtag>"]` tag /// /// This will convert the hashtag to lowercase. diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index baecdf7d8..dbe293180 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -9,7 +9,6 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::str::FromStr; -use hashes::sha1::Hash as Sha1Hash; use hashes::sha256::Hash as Sha256Hash; use secp256k1::schnorr::Signature; @@ -17,7 +16,6 @@ use super::{Error, TagKind}; use crate::event::id::EventId; use crate::nips::nip01::Coordinate; use crate::nips::nip10::Marker; -use crate::nips::nip34::EUC; use crate::nips::nip39::Identity; use crate::nips::nip48::Protocol; use crate::nips::nip53::{LiveEventMarker, LiveEventStatus}; @@ -33,7 +31,6 @@ use crate::{ }; const ALL_RELAYS: &str = "ALL_RELAYS"; -const GIT_REFS_HEADS: &str = "ref: refs/heads/"; /// Standardized tag #[allow(deprecated)] @@ -72,34 +69,6 @@ pub enum TagStandard { /// /// <https://github.com/nostr-protocol/nips/blob/master/56.md> EventReport(EventId, Report), - /// Git head ([`TagKind::Head`] tag) - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitHead(String), - /// Git clone ([`TagKind::Clone`] tag) - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitClone(Vec<Url>), - /// Git commit - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitCommit(Sha1Hash), - /// Git earliest unique commit ID - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitEarliestUniqueCommitId(Sha1Hash), - /// Git repo maintainers - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitMaintainers(Vec<PublicKey>), - /// Git merge base commit - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitMergeBase(Sha1Hash), - /// Git branch name - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - GitBranchName(String), /// Public Key /// /// <https://github.com/nostr-protocol/nips/blob/master/01.md> @@ -390,20 +359,12 @@ impl TagStandard { }); } TagKind::Client => return parse_client_tag(tag), - TagKind::Clone => { - let urls: Vec<Url> = extract_urls(tag)?; - return Ok(Self::GitClone(urls)); - } TagKind::ContentWarning => { return Ok(Self::ContentWarning { reason: extract_optional_string(tag, 1).map(|s| s.to_string()), }); } TagKind::Encrypted => return Ok(Self::Encrypted), - TagKind::Maintainers => { - let public_keys: Vec<PublicKey> = extract_public_keys(tag)?; - return Ok(Self::GitMaintainers(public_keys)); - } TagKind::Protected => return Ok(Self::Protected), TagKind::Relays => { let urls: Vec<RelayUrl> = extract_relay_urls(tag)?; @@ -455,10 +416,6 @@ impl TagStandard { TagKind::Repository => Ok(Self::Repository(tag_1.to_string())), TagKind::Subject => Ok(Self::Subject(tag_1.to_string())), TagKind::Challenge => Ok(Self::Challenge(tag_1.to_string())), - TagKind::Head => Ok(Self::GitHead(tag_1.to_string())), - TagKind::Commit => Ok(Self::GitCommit(Sha1Hash::from_str(tag_1)?)), - TagKind::MergeBase => Ok(Self::GitMergeBase(Sha1Hash::from_str(tag_1)?)), - TagKind::BranchName => Ok(Self::GitBranchName(tag_1.to_string())), TagKind::Title => Ok(Self::Title(tag_1.to_string())), TagKind::Image => Ok(Self::Image(Url::parse(tag_1)?, None)), TagKind::Thumb => Ok(Self::Thumb(Url::parse(tag_1)?, None)), @@ -604,15 +561,6 @@ impl TagStandard { }) } Self::EventReport(..) => TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E)), - Self::GitHead(..) => TagKind::Head, - Self::GitClone(..) => TagKind::Clone, - Self::GitCommit(..) => TagKind::Commit, - Self::GitEarliestUniqueCommitId(..) => { - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)) - } - Self::GitMaintainers(..) => TagKind::Maintainers, - Self::GitMergeBase(..) => TagKind::MergeBase, - Self::GitBranchName(..) => TagKind::BranchName, Self::PublicKey { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::P, uppercase: *uppercase, @@ -811,33 +759,6 @@ impl From<TagStandard> for Vec<String> { TagStandard::EventReport(id, report) => { vec![tag_kind, id.to_hex(), report.to_string()] } - TagStandard::GitHead(branch) => { - vec![tag_kind, format!("{GIT_REFS_HEADS}{branch}")] - } - TagStandard::GitClone(urls) => { - let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); - tag.push(tag_kind); - tag.extend(urls.into_iter().map(|url| url.to_string())); - tag - } - TagStandard::GitCommit(hash) => { - vec![tag_kind, hash.to_string()] - } - TagStandard::GitEarliestUniqueCommitId(commit) => { - vec![tag_kind, commit.to_string(), EUC.to_string()] - } - TagStandard::GitMaintainers(public_keys) => { - let mut tag: Vec<String> = Vec::with_capacity(1 + public_keys.len()); - tag.push(tag_kind); - tag.extend(public_keys.into_iter().map(|val| val.to_string())); - tag - } - TagStandard::GitMergeBase(hash) => { - vec![tag_kind, hash.to_string()] - } - TagStandard::GitBranchName(name) => { - vec![tag_kind, name] - } TagStandard::PublicKeyReport(pk, report) => { vec![tag_kind, pk.to_string(), report.to_string()] } @@ -1283,19 +1204,6 @@ fn parse_r_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> where S: AsRef<str>, { - if tag.len() >= 3 && !uppercase { - let tag_1: &str = tag[1].as_ref(); - let tag_2: &str = tag[2].as_ref(); - - return if tag_2 == EUC { - // ["r", "<commit-id>", "euc"] - let commit: Sha1Hash = Sha1Hash::from_str(tag_1)?; - Ok(TagStandard::GitEarliestUniqueCommitId(commit)) - } else { - Err(Error::UnknownStandardizedTag) - }; - } - if tag.len() >= 2 && !uppercase { let tag_1: &str = tag[1].as_ref(); @@ -1463,18 +1371,6 @@ where Ok(list) } -fn extract_public_keys<S>(tag: &[S]) -> Result<Vec<PublicKey>, Error> -where - S: AsRef<str>, -{ - // Skip index 0 because is the tag kind - let mut list: Vec<PublicKey> = Vec::with_capacity(tag.len().saturating_sub(1)); - for pkey in tag.iter().skip(1) { - list.push(PublicKey::parse(pkey.as_ref())?); - } - Ok(list) -} - #[cfg(test)] mod tests { use alloc::borrow::ToOwned; @@ -2111,41 +2007,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec!["r", "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", "euc"], - TagStandard::GitEarliestUniqueCommitId( - Sha1Hash::from_str("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2").unwrap() - ) - .to_vec() - ); - - assert_eq!( - vec!["clone", "https://github.com/rust-nostr/nostr.git",], - TagStandard::GitClone(vec![ - Url::parse("https://github.com/rust-nostr/nostr.git").unwrap() - ]) - .to_vec() - ); - - assert_eq!( - vec![ - "maintainers", - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ], - TagStandard::GitMaintainers(vec![ - PublicKey::from_hex( - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" - ) - .unwrap(), - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - ]) - .to_vec() - ); - assert_eq!( vec![ "web", @@ -2759,39 +2620,6 @@ mod tests { } ); - assert_eq!( - TagStandard::parse(&["r", "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", "euc"]).unwrap(), - TagStandard::GitEarliestUniqueCommitId( - Sha1Hash::from_str("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2").unwrap() - ) - ); - - assert_eq!( - TagStandard::parse(&["clone", "https://github.com/rust-nostr/nostr.git"]).unwrap(), - TagStandard::GitClone(vec![ - Url::parse("https://github.com/rust-nostr/nostr.git").unwrap() - ]) - ); - - assert_eq!( - TagStandard::parse(&[ - "maintainers", - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::GitMaintainers(vec![ - PublicKey::from_hex( - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" - ) - .unwrap(), - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - ]) - ); - assert_eq!( TagStandard::parse(&[ "web", diff --git a/crates/nostr/src/nips/nip34.rs b/crates/nostr/src/nips/nip34.rs index 5d2a719a5..7fe1fde03 100644 --- a/crates/nostr/src/nips/nip34.rs +++ b/crates/nostr/src/nips/nip34.rs @@ -8,20 +8,464 @@ #![allow(clippy::wrong_self_convention)] -use alloc::borrow::Cow; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; +use core::str::FromStr; +use hashes::hex::HexToArrayError; use hashes::sha1::Hash as Sha1Hash; -use crate::event::builder::{Error, EventBuilder, WrongKindError}; -use crate::nips::nip01::{self, Coordinate}; -use crate::types::url::Url; -use crate::{EventId, Kind, PublicKey, RelayUrl, Tag, TagKind, TagStandard, Timestamp}; +use super::nip01::{self, Coordinate}; +use super::nip22::Nip22Tag; +use super::util::{take_and_parse_from_str, take_string}; +use crate::event::builder::{Error as BuilderError, EventBuilder, WrongKindError}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, Url}; +use crate::{EventId, Kind, PublicKey, RelayUrl, Timestamp, key}; + +const EUC: &str = "euc"; +const APPLIED_AS_COMMITS: &str = "applied-as-commits"; +const BRANCH_NAME: &str = "branch-name"; +const CLONE: &str = "clone"; +const COMMIT: &str = "commit"; +const COMMIT_PGP_SIG: &str = "commit-pgp-sig"; +const COMMITTER: &str = "committer"; +const CURRENT_COMMIT: &str = "c"; +const DESCRIPTION: &str = "description"; +const GRASP: &str = "g"; +const HEAD: &str = "HEAD"; +const MAINTAINERS: &str = "maintainers"; +const MERGE_BASE: &str = "merge-base"; +const MERGE_COMMIT: &str = "merge-commit"; +const NAME: &str = "name"; +const PARENT_COMMIT: &str = "parent-commit"; +const REFERENCE: &str = "r"; +const REFS_HEADS: &str = "refs/heads/"; +const REFS_TAGS: &str = "refs/tags/"; +const SUBJECT: &str = "subject"; +const WEB: &str = "web"; +const RELAYS: &str = "relays"; + +/// NIP-34 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Keys error + Keys(key::Error), + /// NIP-01 error + Nip01(nip01::Error), + /// Relay URL error + RelayUrl(url::Error), + /// URL error + Url(url::ParseError), + /// Hex to array error + Hex(HexToArrayError), + /// Parse integer error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), + /// Invalid `HEAD` tag + InvalidHeadTag, +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Keys(e) => e.fmt(f), + Self::Nip01(e) => e.fmt(f), + Self::RelayUrl(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Hex(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + Self::InvalidHeadTag => f.write_str("Invalid HEAD tag"), + } + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::Nip01(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::RelayUrl(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<HexToArrayError> for Error { + fn from(e: HexToArrayError) -> Self { + Self::Hex(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-34 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/34.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip34Tag { + /// `applied-as-commits` tag + AppliedAsCommits(Vec<Sha1Hash>), + /// `branch-name` tag + BranchName(String), + /// `clone` tag + Clone(Vec<Url>), + /// `commit` tag + Commit(Sha1Hash), + /// `commit-pgp-sig` tag + CommitPgpSig(String), + /// `committer` tag + Committer { + /// Name + name: String, + /// Email + email: String, + /// Timestamp + timestamp: Timestamp, + /// Timezone offset in minutes + offset_minutes: i32, + }, + /// `c` tag + CurrentCommit(Sha1Hash), + /// `description` tag + Description(String), + /// `r` tag with `euc` marker + EarliestUniqueCommitId(Sha1Hash), + /// `g` tag + Grasp(RelayUrl), + /// `HEAD` tag + Head(String), + /// `maintainers` tag + Maintainers(Vec<PublicKey>), + /// `merge-base` tag + MergeBase(Sha1Hash), + /// `merge-commit` tag + MergeCommit(Sha1Hash), + /// `name` tag + Name(String), + /// `parent-commit` tag + ParentCommit(Sha1Hash), + /// `r` tag + Reference(Sha1Hash), + /// `refs/heads/<branch>` tag + RefHead { + /// Branch name + branch: String, + /// Commit ID + commit: Sha1Hash, + }, + /// `refs/tags/<tag>` tag + RefTag { + /// Tag name + name: String, + /// Commit ID + commit: Sha1Hash, + }, + /// `relays` tag + Relays(Vec<RelayUrl>), + /// `subject` tag + Subject(String), + /// `web` tag + Web(Vec<Url>), +} + +impl TagCodec for Nip34Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + let kind: &str = kind.as_ref(); + + match kind { + APPLIED_AS_COMMITS => Ok(Self::AppliedAsCommits(parse_commit_list(iter)?)), + BRANCH_NAME => Ok(Self::BranchName(take_string(&mut iter, "branch name")?)), + CLONE => Ok(Self::Clone(parse_url_list(iter)?)), + COMMIT => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "commit ID")?; + Ok(Self::Commit(commit)) + } + COMMIT_PGP_SIG => Ok(Self::CommitPgpSig(take_string( + &mut iter, + "commit pgp signature", + )?)), + COMMITTER => { + let name: String = iter + .next() + .map(|value| value.as_ref().to_string()) + .unwrap_or_default(); + let email: String = iter + .next() + .map(|value| value.as_ref().to_string()) + .unwrap_or_default(); + let timestamp: Timestamp = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "committer timestamp")?; + let offset_minutes: i32 = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "committer offset")?; + + Ok(Self::Committer { + name, + email, + timestamp, + offset_minutes, + }) + } + CURRENT_COMMIT => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "current commit ID")?; + Ok(Self::CurrentCommit(commit)) + } + DESCRIPTION => Ok(Self::Description(take_string(&mut iter, "description")?)), + GRASP => { + let relay: RelayUrl = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "grasp relay URL")?; + Ok(Self::Grasp(relay)) + } + HEAD => { + let head: S = iter.next().ok_or(TagCodecError::Missing("head"))?; + let branch: &str = head + .as_ref() + .strip_prefix("ref: refs/heads/") + .ok_or(Error::InvalidHeadTag)?; + Ok(Self::Head(branch.to_string())) + } + MAINTAINERS => Ok(Self::Maintainers(parse_public_keys(iter)?)), + MERGE_BASE => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "merge base")?; + Ok(Self::MergeBase(commit)) + } + MERGE_COMMIT => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "merge commit ID")?; + Ok(Self::MergeCommit(commit)) + } + NAME => Ok(Self::Name(take_string(&mut iter, "name")?)), + PARENT_COMMIT => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "parent commit ID")?; + Ok(Self::ParentCommit(commit)) + } + REFERENCE => { + let commit: Sha1Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "reference commit ID")?; + match iter.next() { + Some(marker) if marker.as_ref() == EUC => { + Ok(Self::EarliestUniqueCommitId(commit)) + } + Some(_) => Err(TagCodecError::Unknown.into()), + None => Ok(Self::Reference(commit)), + } + } + SUBJECT => Ok(Self::Subject(take_string(&mut iter, "subject")?)), + WEB => Ok(Self::Web(parse_url_list(iter)?)), + RELAYS => Ok(Self::Relays(parse_relay_urls(iter)?)), + _ if kind.starts_with(REFS_HEADS) => { + let commit: S = iter.next().ok_or(TagCodecError::Missing("commit id"))?; + Ok(Self::RefHead { + branch: kind.trim_start_matches(REFS_HEADS).to_string(), + commit: Sha1Hash::from_str(commit.as_ref())?, + }) + } + _ if kind.starts_with(REFS_TAGS) => { + let commit: S = iter.next().ok_or(TagCodecError::Missing("commit id"))?; + Ok(Self::RefTag { + name: kind.trim_start_matches(REFS_TAGS).to_string(), + commit: Sha1Hash::from_str(commit.as_ref())?, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::AppliedAsCommits(commits) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + commits.len()); + tag.push(String::from(APPLIED_AS_COMMITS)); + tag.extend(commits.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::BranchName(name) => Tag::new(vec![String::from(BRANCH_NAME), name.clone()]), + Self::Clone(urls) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); + tag.push(String::from(CLONE)); + tag.extend(urls.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::Commit(commit) => Tag::new(vec![String::from(COMMIT), commit.to_string()]), + Self::CommitPgpSig(signature) => { + Tag::new(vec![String::from(COMMIT_PGP_SIG), signature.clone()]) + } + Self::Committer { + name, + email, + timestamp, + offset_minutes, + } => Tag::new(vec![ + String::from(COMMITTER), + name.clone(), + email.clone(), + timestamp.to_string(), + offset_minutes.to_string(), + ]), + Self::CurrentCommit(commit) => { + Tag::new(vec![String::from(CURRENT_COMMIT), commit.to_string()]) + } + Self::Description(description) => { + Tag::new(vec![String::from(DESCRIPTION), description.clone()]) + } + Self::EarliestUniqueCommitId(commit) => Tag::new(vec![ + String::from(REFERENCE), + commit.to_string(), + String::from(EUC), + ]), + Self::Grasp(relay) => Tag::new(vec![String::from(GRASP), relay.to_string()]), + Self::Head(branch) => Tag::new(vec![ + String::from(HEAD), + format!("ref: refs/heads/{branch}"), + ]), + Self::Maintainers(public_keys) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + public_keys.len()); + tag.push(String::from(MAINTAINERS)); + tag.extend(public_keys.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::MergeBase(commit) => Tag::new(vec![String::from(MERGE_BASE), commit.to_string()]), + Self::MergeCommit(commit) => { + Tag::new(vec![String::from(MERGE_COMMIT), commit.to_string()]) + } + Self::Name(name) => Tag::new(vec![String::from(NAME), name.clone()]), + Self::ParentCommit(commit) => { + Tag::new(vec![String::from(PARENT_COMMIT), commit.to_string()]) + } + Self::Reference(commit) => Tag::new(vec![String::from(REFERENCE), commit.to_string()]), + Self::RefHead { branch, commit } => { + Tag::new(vec![format!("{REFS_HEADS}{branch}"), commit.to_string()]) + } + Self::RefTag { name, commit } => { + Tag::new(vec![format!("{REFS_TAGS}{name}"), commit.to_string()]) + } + Self::Relays(relays) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + relays.len()); + tag.push(String::from(RELAYS)); + tag.extend(relays.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::Subject(subject) => Tag::new(vec![String::from(SUBJECT), subject.clone()]), + Self::Web(urls) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); + tag.push(String::from(WEB)); + tag.extend(urls.iter().map(ToString::to_string)); + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip34Tag); + +fn parse_commit_list<I, S>(iter: I) -> Result<Vec<Sha1Hash>, Error> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let values: Vec<Sha1Hash> = iter + .into_iter() + .map(|value| Sha1Hash::from_str(value.as_ref())) + .collect::<Result<_, _>>()?; + + if values.is_empty() { + return Err(TagCodecError::Missing("commits").into()); + } + + Ok(values) +} + +fn parse_public_keys<I, S>(iter: I) -> Result<Vec<PublicKey>, Error> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let values: Vec<PublicKey> = iter + .into_iter() + .map(|value| PublicKey::from_hex(value.as_ref())) + .collect::<Result<_, _>>()?; + + if values.is_empty() { + return Err(TagCodecError::Missing("public keys").into()); + } + + Ok(values) +} + +fn parse_relay_urls<I, S>(iter: I) -> Result<Vec<RelayUrl>, Error> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let values: Vec<RelayUrl> = iter + .into_iter() + .map(|value| RelayUrl::parse(value.as_ref())) + .collect::<Result<_, _>>()?; + + if values.is_empty() { + return Err(TagCodecError::Missing("relay URLs").into()); + } + + Ok(values) +} + +fn parse_url_list<I, S>(iter: I) -> Result<Vec<Url>, Error> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + let values: Vec<Url> = iter + .into_iter() + .map(|value| Url::parse(value.as_ref())) + .collect::<Result<_, _>>()?; + + if values.is_empty() { + return Err(TagCodecError::Missing("URLs").into()); + } -/// Earlier unique commit ID marker -pub const EUC: &str = "euc"; + Ok(values) +} /// Git Repository Announcement /// @@ -53,10 +497,10 @@ pub struct GitRepositoryAnnouncement { } impl GitRepositoryAnnouncement { - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { if self.id.is_empty() { // TODO: should return another error? - return Err(Error::NIP01(nip01::Error::InvalidCoordinate)); + return Err(BuilderError::NIP01(nip01::Error::InvalidCoordinate)); } let mut tags: Vec<Tag> = Vec::with_capacity(1); @@ -66,43 +510,37 @@ impl GitRepositoryAnnouncement { // Add name if let Some(name) = self.name { - tags.push(Tag::from_standardized(TagStandard::Name(name))); + tags.push(Nip34Tag::Name(name).to_tag()); } // Add description if let Some(description) = self.description { - tags.push(Tag::from_standardized(TagStandard::Description( - description, - ))); + tags.push(Nip34Tag::Description(description).to_tag()); } // Add web if !self.web.is_empty() { - tags.push(Tag::from_standardized(TagStandard::Web(self.web))); + tags.push(Nip34Tag::Web(self.web).to_tag()); } // Add clone if !self.clone.is_empty() { - tags.push(Tag::from_standardized(TagStandard::GitClone(self.clone))); + tags.push(Nip34Tag::Clone(self.clone).to_tag()); } // Add relays if !self.relays.is_empty() { - tags.push(Tag::from_standardized(TagStandard::Relays(self.relays))); + tags.push(Nip34Tag::Relays(self.relays).to_tag()); } // Add EUC if let Some(commit) = self.euc { - tags.push(Tag::from_standardized( - TagStandard::GitEarliestUniqueCommitId(commit), - )); + tags.push(Nip34Tag::EarliestUniqueCommitId(commit).to_tag()); } // Add maintainers if !self.maintainers.is_empty() { - tags.push(Tag::from_standardized(TagStandard::GitMaintainers( - self.maintainers, - ))); + tags.push(Nip34Tag::Maintainers(self.maintainers).to_tag()); } // Build @@ -125,10 +563,10 @@ pub struct GitIssue { impl GitIssue { /// Based on <https://github.com/nostr-protocol/nips/blob/ea36ec9ed7596e49bf7f217b05954c1fecacad88/34.md> revision. - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { // Check if repository address kind is wrong if self.repository.kind != Kind::GitRepoAnnouncement { - return Err(Error::WrongKind { + return Err(BuilderError::WrongKind { received: self.repository.kind, expected: WrongKindError::Single(Kind::GitRepoAnnouncement), }); @@ -149,7 +587,7 @@ impl GitIssue { // Add subject if let Some(subject) = self.subject { - tags.push(Tag::from_standardized(TagStandard::Subject(subject))); + tags.push(Nip34Tag::Subject(subject).to_tag()); } // Add labels @@ -235,10 +673,10 @@ pub struct GitPatch { } impl GitPatch { - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { // Check if repository address kind is wrong if self.repository.kind != Kind::GitRepoAnnouncement { - return Err(Error::WrongKind { + return Err(BuilderError::WrongKind { received: self.repository.kind, expected: WrongKindError::Single(Kind::GitRepoAnnouncement), }); @@ -258,7 +696,7 @@ impl GitPatch { tags.push(Tag::public_key(owner_public_key)); // Add EUC (without `euc` marker) - tags.push(Tag::reference(self.euc.to_string())); + tags.push(Nip34Tag::Reference(self.euc).to_tag()); // Serialize content to string (used later) let content: String = self.content.to_string(); @@ -277,25 +715,19 @@ impl GitPatch { .. } => { tags.reserve_exact(5); - tags.push(Tag::reference(commit.to_string())); - tags.push(Tag::from_standardized(TagStandard::GitCommit(commit))); - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("parent-commit")), - vec![parent_commit.to_string()], - )); - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("commit-pgp-sig")), - vec![commit_pgp_sig.unwrap_or_default()], - )); - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("committer")), - vec![ - committer.name.unwrap_or_default(), - committer.email.unwrap_or_default(), - committer.timestamp.to_string(), - committer.offset_minutes.to_string(), - ], - )); + tags.push(Nip34Tag::Reference(commit).to_tag()); + tags.push(Nip34Tag::Commit(commit).to_tag()); + tags.push(Nip34Tag::ParentCommit(parent_commit).to_tag()); + tags.push(Nip34Tag::CommitPgpSig(commit_pgp_sig.unwrap_or_default()).to_tag()); + tags.push( + Nip34Tag::Committer { + name: committer.name.unwrap_or_default(), + email: committer.email.unwrap_or_default(), + timestamp: committer.timestamp, + offset_minutes: committer.offset_minutes, + } + .to_tag(), + ); } } @@ -331,10 +763,10 @@ pub struct GitPullRequest { } impl GitPullRequest { - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { // Check if repository address kind is wrong if self.repository.kind != Kind::GitRepoAnnouncement { - return Err(Error::WrongKind { + return Err(BuilderError::WrongKind { received: self.repository.kind, expected: WrongKindError::Single(Kind::GitRepoAnnouncement), }); @@ -357,28 +789,23 @@ impl GitPullRequest { // Add subject if let Some(subject) = self.subject { - tags.push(Tag::from_standardized(TagStandard::Subject(subject))); + tags.push(Nip34Tag::Subject(subject).to_tag()); } // Add labels tags.extend(self.labels.into_iter().map(Tag::hashtag)); // Add current commit - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("c")), - vec![self.current_commit.to_string()], - )); + tags.push(Nip34Tag::CurrentCommit(self.current_commit).to_tag()); // Add clone URLs if !self.clone.is_empty() { - tags.push(Tag::from_standardized(TagStandard::GitClone(self.clone))); + tags.push(Nip34Tag::Clone(self.clone).to_tag()); } // Add branch name if let Some(branch_name) = self.branch_name { - tags.push(Tag::from_standardized(TagStandard::GitBranchName( - branch_name, - ))); + tags.push(Nip34Tag::BranchName(branch_name).to_tag()); } // Add root patch event (if this is a revision) @@ -388,9 +815,7 @@ impl GitPullRequest { // Add merge base if let Some(merge_base) = self.merge_base { - tags.push(Tag::from_standardized(TagStandard::GitMergeBase( - merge_base, - ))); + tags.push(Nip34Tag::MergeBase(merge_base).to_tag()); } // Build @@ -416,10 +841,10 @@ pub struct GitPullRequestUpdate { } impl GitPullRequestUpdate { - pub(crate) fn to_event_builder(self) -> Result<EventBuilder, Error> { + pub(crate) fn to_event_builder(self) -> Result<EventBuilder, BuilderError> { // Check if repository address kind is wrong if self.repository.kind != Kind::GitRepoAnnouncement { - return Err(Error::WrongKind { + return Err(BuilderError::WrongKind { received: self.repository.kind, expected: WrongKindError::Single(Kind::GitRepoAnnouncement), }); @@ -440,31 +865,35 @@ impl GitPullRequestUpdate { tags.push(Tag::public_key(owner_public_key)); // Add NIP-22 tags for the pull request being updated - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("E")), - vec![self.pull_request_event.to_string()], - )); - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("P")), - vec![self.pull_request_author.to_string()], - )); + tags.push( + Nip22Tag::Event { + id: self.pull_request_event, + relay_hint: None, + public_key: None, + uppercase: true, + } + .to_tag(), + ); + tags.push( + Nip22Tag::PublicKey { + public_key: self.pull_request_author, + relay_hint: None, + uppercase: true, + } + .to_tag(), + ); // Add updated current commit - tags.push(Tag::custom( - TagKind::Custom(Cow::Borrowed("c")), - vec![self.current_commit.to_string()], - )); + tags.push(Nip34Tag::CurrentCommit(self.current_commit).to_tag()); // Add clone URLs if !self.clone.is_empty() { - tags.push(Tag::from_standardized(TagStandard::GitClone(self.clone))); + tags.push(Nip34Tag::Clone(self.clone).to_tag()); } // Add merge base if let Some(merge_base) = self.merge_base { - tags.push(Tag::from_standardized(TagStandard::GitMergeBase( - merge_base, - ))); + tags.push(Nip34Tag::MergeBase(merge_base).to_tag()); } // Build @@ -486,7 +915,7 @@ impl GitUserGraspList { let tags: Vec<Tag> = self .grasp_servers .into_iter() - .map(|url| Tag::custom(TagKind::Custom(Cow::Borrowed("g")), vec![url.to_string()])) + .map(|url| Nip34Tag::Grasp(url).to_tag()) .collect(); EventBuilder::new(Kind::GitUserGraspList, "").tags(tags) @@ -545,6 +974,30 @@ mod tests { assert_eq!(event.tags, tags); } + #[test] + fn test_standardized_git_head_tag() { + let tag = vec![String::from("HEAD"), String::from("ref: refs/heads/main")]; + let parsed = Nip34Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip34Tag::Head(String::from("main"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_applied_as_commits_tag() { + let commit_1 = Sha1Hash::from_str("59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca").unwrap(); + let commit_2 = Sha1Hash::from_str("b1fa697b5cd42fbb6ec9fef9009609200387e0b4").unwrap(); + let tag = vec![ + String::from("applied-as-commits"), + commit_1.to_string(), + commit_2.to_string(), + ]; + let parsed = Nip34Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip34Tag::AppliedAsCommits(vec![commit_1, commit_2])); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + #[test] fn test_git_issue() { let pk = @@ -647,4 +1100,63 @@ mod tests { .unwrap(); assert_eq!(event.tags, tags); } + + #[test] + fn test_git_pull_request_update() { + let pk = + PublicKey::parse("npub1drvpzev3syqt0kjrls50050uzf25gehpz9vgdw08hvex7e0vgfeq0eseet") + .unwrap(); + let repository = Coordinate::new(Kind::GitRepoAnnouncement, pk).identifier("rust-nostr"); + let pull_request_event = + EventId::from_hex("70b09cb6f4f6b2b8c3d2b6f0bbf8f4f6ca1d9c1df4d5f85f0dbb2a7a9c7c4f21") + .unwrap(); + let pull_request_author = + PublicKey::from_hex("68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272") + .unwrap(); + + let update = GitPullRequestUpdate { + repository, + pull_request_event, + pull_request_author, + current_commit: Sha1Hash::from_str("b1fa697b5cd42fbb6ec9fef9009609200387e0b4").unwrap(), + clone: vec![Url::parse("https://github.com/rust-nostr/nostr.git").unwrap()], + merge_base: Some( + Sha1Hash::from_str("c88d901b42ff8389330d6d5d4044cf1d196696f3").unwrap(), + ), + }; + + let keys = Keys::generate(); + let event: Event = update + .to_event_builder() + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + + assert_eq!(event.kind, Kind::GitPullRequestUpdate); + assert!(event.content.is_empty()); + + let tags = Tags::parse([ + vec![ + "a", + "30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:rust-nostr", + ], + vec![ + "p", + "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272", + ], + vec![ + "E", + "70b09cb6f4f6b2b8c3d2b6f0bbf8f4f6ca1d9c1df4d5f85f0dbb2a7a9c7c4f21", + ], + vec![ + "P", + "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272", + ], + vec!["c", "b1fa697b5cd42fbb6ec9fef9009609200387e0b4"], + vec!["clone", "https://github.com/rust-nostr/nostr.git"], + vec!["merge-base", "c88d901b42ff8389330d6d5d4044cf1d196696f3"], + ]) + .unwrap(); + assert_eq!(event.tags, tags); + } } From 461f7d34f4b47a2c1aeeb2c8137a906d164f2c09 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 20 Apr 2026 14:33:59 +0200 Subject: [PATCH 11/40] nostr: rework NIP-18 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 75 +++--- crates/nostr/src/event/tag/standard.rs | 253 ------------------ crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip18.rs | 356 +++++++++++++++++++++++++ crates/nostr/src/prelude.rs | 1 + 5 files changed, 393 insertions(+), 293 deletions(-) create mode 100644 crates/nostr/src/nips/nip18.rs diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 0dc83b6d4..4d39d7b49 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -551,15 +551,16 @@ impl EventBuilder { if event.kind == Kind::TextNote { Self::new(Kind::Repost, content).tags([ - Tag::from_standardized(TagStandard::Event { - event_id: event.id, - relay_url, - marker: None, - // NOTE: not add public key since it's already included as `p` tag - public_key: None, - uppercase: false, - }), - Tag::public_key(event.pubkey), + Nip18Tag::Event { + id: event.id, + relay_hint: relay_url, + } + .to_tag(), + Nip18Tag::PublicKey { + public_key: event.pubkey, + relay_hint: None, + } + .to_tag(), ]) } else { Self::new(Kind::GenericRepost, content) @@ -569,19 +570,17 @@ impl EventBuilder { .map(|c| Tag::coordinate(c, relay_url.clone())), ) .tags([ - Tag::from_standardized(TagStandard::Event { - event_id: event.id, - relay_url, - marker: None, - // NOTE: not add public key since it's already included as `p` tag - public_key: None, - uppercase: false, - }), - Tag::public_key(event.pubkey), - Tag::from_standardized(TagStandard::Kind { - kind: event.kind, - uppercase: false, - }), + Nip18Tag::Event { + id: event.id, + relay_hint: relay_url, + } + .to_tag(), + Nip18Tag::PublicKey { + public_key: event.pubkey, + relay_hint: None, + } + .to_tag(), + Nip18Tag::Kind(event.kind).to_tag(), ]) } } @@ -1823,13 +1822,14 @@ impl EventBuilder { content = format!("{}\n{content}", nevent.to_nostr_uri()?); } - Ok( - Self::new(Kind::ChatMessage, content).tag(Tag::from_standardized(TagStandard::Quote { - event_id: reply_to.id, - relay_url, + Ok(Self::new(Kind::ChatMessage, content).tag( + Nip18Tag::Quote { + id: reply_to.id, + relay_hint: relay_url, public_key: Some(reply_to.pubkey), - })), - ) + } + .to_tag(), + )) } /// Thread @@ -2268,22 +2268,17 @@ mod tests { assert_eq!(repost.kind, Kind::Repost); assert_eq!(repost.content, note.as_json()); assert_eq!( - repost.tags[0].standardized().unwrap(), - TagStandard::Event { - event_id: note.id, - relay_url: Some(relay_url), - marker: None, - public_key: None, - uppercase: false + Nip18Tag::try_from(&repost.tags[0]).unwrap(), + Nip18Tag::Event { + id: note.id, + relay_hint: Some(relay_url), } ); assert_eq!( - repost.tags[1].standardized().unwrap(), - TagStandard::PublicKey { + Nip18Tag::try_from(&repost.tags[1]).unwrap(), + Nip18Tag::PublicKey { public_key: note.pubkey, - relay_url: None, - alias: None, - uppercase: false + relay_hint: None, } ); } diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index dbe293180..2aede5e9d 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -49,22 +49,6 @@ pub enum TagStandard { /// Whether the tag is an uppercase or not uppercase: bool, }, - /// Quote - /// - /// <https://github.com/nostr-protocol/nips/blob/master/18.md> - Quote { - event_id: EventId, - relay_url: Option<RelayUrl>, - /// Should be the public key of the author of the referenced event - public_key: Option<PublicKey>, - }, - /// Quote address - /// - /// <https://github.com/nostr-protocol/nips/blob/master/22.md> - QuoteAddress { - coordinate: Coordinate, - relay_url: Option<RelayUrl>, - }, /// Report event /// /// <https://github.com/nostr-protocol/nips/blob/master/56.md> @@ -330,13 +314,6 @@ impl TagStandard { } => { return parse_r_tag(tag, uppercase); } - // Parse `q` tag - SingleLetterTag { - character: Alphabet::Q, - uppercase: false, - } => { - return parse_q_tag(tag); - } // Parse `t` tag SingleLetterTag { character: Alphabet::T, @@ -554,12 +531,6 @@ impl TagStandard { character: Alphabet::E, uppercase: *uppercase, }), - Self::Quote { .. } | Self::QuoteAddress { .. } => { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::Q, - uppercase: false, - }) - } Self::EventReport(..) => TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E)), Self::PublicKey { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::P, @@ -719,32 +690,6 @@ impl From<TagStandard> for Vec<String> { tag } - TagStandard::Quote { - event_id, - relay_url, - public_key, - } => { - let mut tag = vec![tag_kind, event_id.to_hex()]; - if let Some(relay_url) = relay_url { - tag.push(relay_url.to_string()); - } - if let Some(public_key) = public_key { - // If <relay-url> is `None`, push an empty string - tag.resize_with(3, String::new); - tag.push(public_key.to_hex()); - } - tag - } - TagStandard::QuoteAddress { - coordinate, - relay_url, - } => { - let mut tag = vec![tag_kind, coordinate.to_string()]; - if let Some(relay_url) = relay_url { - tag.push(relay_url.to_string()); - } - tag - } TagStandard::PublicKey { public_key, relay_url, @@ -1213,44 +1158,6 @@ where Err(Error::UnknownStandardizedTag) } -fn parse_q_tag<S>(tag: &[S]) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() < 2 { - return Err(Error::UnknownStandardizedTag); - } - - let tag_1 = tag[1].as_ref(); - let tag_2: Option<&str> = tag.get(2).map(|r| r.as_ref()); - - let relay_url: Option<RelayUrl> = match tag_2 { - Some(url) if !url.is_empty() => Some(RelayUrl::parse(url)?), - _ => None, - }; - - match EventId::from_hex(tag_1) { - Ok(event_id) => { - let tag_3: Option<&str> = tag.get(3).map(|r| r.as_ref()); - - let public_key: Option<PublicKey> = match tag_3 { - Some(public_key) => Some(PublicKey::from_hex(public_key)?), - None => None, - }; - - Ok(TagStandard::Quote { - event_id, - relay_url, - public_key, - }) - } - Err(_) => Ok(TagStandard::QuoteAddress { - coordinate: Coordinate::from_str(tag_1)?, - relay_url, - }), - } -} - fn parse_t_tag<S>(tag: &[S]) -> Result<TagStandard, Error> where S: AsRef<str>, @@ -1528,87 +1435,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec![ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ], - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - public_key: None, - } - .to_vec() - ); - - assert_eq!( - vec![ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "wss://relay.damus.io" - ], - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - public_key: None, - } - .to_vec() - ); - - assert_eq!( - vec![ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ], - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - public_key: Some( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ), - } - .to_vec() - ); - - assert_eq!( - vec![ - "q", - "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", - ], - TagStandard::QuoteAddress { - coordinate: Coordinate::from_str("30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7").unwrap(), - relay_url: None, - } - .to_vec() - ); - - assert_eq!( - vec![ - "q", - "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", - "wss://relay.damus.io" - ], - TagStandard::QuoteAddress { - coordinate: Coordinate::from_str("30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7").unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - } - .to_vec() - ); - assert_eq!( vec!["content-warning", "reason"], TagStandard::ContentWarning { @@ -2080,85 +1906,6 @@ mod tests { }) ); - assert_eq!( - TagStandard::parse(&[ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ]) - .unwrap(), - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - public_key: None, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "wss://relay.damus.io" - ]) - .unwrap(), - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - public_key: None, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "q", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::Quote { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - public_key: Some( - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ), - } - ); - - assert_eq!( - TagStandard::parse(&[ - "q", - "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", - ]).unwrap(), - TagStandard::QuoteAddress { - coordinate: Coordinate::from_str("30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7").unwrap(), - relay_url: None, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "q", - "30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7", - "wss://relay.damus.io" - ]).unwrap(), - TagStandard::QuoteAddress { - coordinate: Coordinate::from_str("30023:3c9849383bdea883b0bd16fece1ed36d37e37cdde3ce43b17ea4e9192ec11289:f9347ca7").unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - } - ); - assert_eq!( TagStandard::parse(&["content-warning", "reason"]).unwrap(), TagStandard::ContentWarning { diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index f4e86f26b..91af58865 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -18,6 +18,7 @@ pub mod nip11; pub mod nip13; pub mod nip15; pub mod nip17; +pub mod nip18; pub mod nip19; pub mod nip21; pub mod nip22; diff --git a/crates/nostr/src/nips/nip18.rs b/crates/nostr/src/nips/nip18.rs new file mode 100644 index 000000000..6ff645a4b --- /dev/null +++ b/crates/nostr/src/nips/nip18.rs @@ -0,0 +1,356 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-18: Reposts +//! +//! <https://github.com/nostr-protocol/nips/blob/master/18.md> + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; +use core::num::ParseIntError; + +use super::nip01::{self, Coordinate}; +use super::util::{ + take_and_parse_from_str, take_and_parse_optional_public_key, take_and_parse_optional_relay_url, + take_event_id, take_public_key, +}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::event::{self}; +use crate::types::url; +use crate::{EventId, Kind, PublicKey, RelayUrl, key}; + +const EVENT: &str = "e"; +const KIND: &str = "k"; +const PUBLIC_KEY: &str = "p"; +const QUOTE: &str = "q"; + +/// NIP-18 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Event error + Event(event::Error), + /// Keys error + Keys(key::Error), + /// NIP-01 error + Nip01(nip01::Error), + /// Relay URL error + RelayUrl(url::Error), + /// Parse int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Event(e) => e.fmt(f), + Self::Keys(e) => e.fmt(f), + Self::Nip01(e) => e.fmt(f), + Self::RelayUrl(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::Nip01(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::RelayUrl(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-18 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/18.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip18Tag { + /// `e` tag + Event { + /// Event ID + id: EventId, + /// Relay hint + relay_hint: Option<RelayUrl>, + }, + /// `k` tag + Kind(Kind), + /// `p` tag + PublicKey { + /// Public key + public_key: PublicKey, + /// Relay hint + relay_hint: Option<RelayUrl>, + }, + /// `q` tag with event ID + Quote { + /// Event ID + id: EventId, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Public key hint + public_key: Option<PublicKey>, + }, + /// `q` tag with event coordinate + QuoteAddress { + /// Event coordinate + coordinate: Coordinate, + /// Relay hint + relay_hint: Option<RelayUrl>, + }, +} + +impl TagCodec for Nip18Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + EVENT => { + let (id, relay_hint) = parse_e_tag(iter)?; + Ok(Self::Event { id, relay_hint }) + } + KIND => { + let kind: Kind = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "kind")?; + Ok(Self::Kind(kind)) + } + PUBLIC_KEY => { + let (public_key, relay_hint) = parse_p_tag(iter)?; + Ok(Self::PublicKey { + public_key, + relay_hint, + }) + } + QUOTE => parse_q_tag(iter), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Event { id, relay_hint } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + tag.push(String::from(EVENT)); + tag.push(id.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) + } + Self::Kind(kind) => Tag::new(vec![String::from(KIND), kind.as_u16().to_string()]), + Self::PublicKey { + public_key, + relay_hint, + } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + tag.push(String::from(PUBLIC_KEY)); + tag.push(public_key.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) + } + Self::Quote { + id, + relay_hint, + public_key, + } => { + let mut tag: Vec<String> = Vec::with_capacity( + 2 + relay_hint.is_some() as usize + public_key.is_some() as usize, + ); + tag.push(String::from(QUOTE)); + tag.push(id.to_hex()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } else if public_key.is_some() { + tag.push(String::new()); + } + + if let Some(public_key) = public_key { + tag.push(public_key.to_hex()); + } + + Tag::new(tag) + } + Self::QuoteAddress { + coordinate, + relay_hint, + } => { + let mut tag: Vec<String> = Vec::with_capacity(2 + relay_hint.is_some() as usize); + tag.push(String::from(QUOTE)); + tag.push(coordinate.to_string()); + + if let Some(relay_hint) = relay_hint { + tag.push(relay_hint.to_string()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip18Tag); + +fn parse_e_tag<T, S>(mut iter: T) -> Result<(EventId, Option<RelayUrl>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + Ok((id, relay_hint)) +} + +fn parse_p_tag<T, S>(mut iter: T) -> Result<(PublicKey, Option<RelayUrl>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + Ok((public_key, relay_hint)) +} + +fn parse_q_tag<T, S>(mut iter: T) -> Result<Nip18Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let value: S = iter.next().ok_or(TagCodecError::Missing("event ID"))?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + match EventId::from_hex(value.as_ref()) { + Ok(id) => { + let public_key: Option<PublicKey> = take_and_parse_optional_public_key(&mut iter)?; + + Ok(Nip18Tag::Quote { + id, + relay_hint, + public_key, + }) + } + Err(_) => Ok(Nip18Tag::QuoteAddress { + coordinate: Coordinate::from_kpi_format(value.as_ref())?, + relay_hint, + }), + } +} + +#[cfg(all(test, feature = "std", feature = "os-rng"))] +mod tests { + use super::*; + use crate::prelude::*; + + #[test] + fn test_standardized_event_tag() { + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("e"), + EventId::all_zeros().to_hex(), + relay_hint.to_string(), + ]; + let parsed = Nip18Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip18Tag::Event { + id: EventId::all_zeros(), + relay_hint: Some(relay_hint), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_quote_tag() { + let keys = Keys::generate(); + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("q"), + EventId::all_zeros().to_hex(), + relay_hint.to_string(), + keys.public_key().to_string(), + ]; + let parsed = Nip18Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip18Tag::Quote { + id: EventId::all_zeros(), + relay_hint: Some(relay_hint), + public_key: Some(keys.public_key()), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_quote_address_tag() { + let keys = Keys::generate(); + let coordinate = + Coordinate::new(Kind::LongFormTextNote, keys.public_key()).identifier("article"); + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let tag = vec![ + String::from("q"), + coordinate.to_string(), + relay_hint.to_string(), + ]; + let parsed = Nip18Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip18Tag::QuoteAddress { + coordinate, + relay_hint: Some(relay_hint), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 66471da44..542c9459c 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -41,6 +41,7 @@ pub use crate::nips::nip11::{self, *}; pub use crate::nips::nip13::{self, *}; pub use crate::nips::nip15::{self, *}; pub use crate::nips::nip17::{self, *}; +pub use crate::nips::nip18::{self, *}; pub use crate::nips::nip19::{self, *}; pub use crate::nips::nip21::{self, *}; pub use crate::nips::nip22::{self, *}; From 544026463bd3d092e9cafdf5b22838264405cf80 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 21 Apr 2026 17:59:34 +0200 Subject: [PATCH 12/40] nostr: rework NIP-70 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/mod.rs | 2 +- crates/nostr/src/event/tag/kind.rs | 10 --- crates/nostr/src/event/tag/mod.rs | 5 +- crates/nostr/src/event/tag/standard.rs | 11 ---- crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip70.rs | 90 ++++++++++++++++++++++++++ crates/nostr/src/prelude.rs | 1 + 7 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 crates/nostr/src/nips/nip70.rs diff --git a/crates/nostr/src/event/mod.rs b/crates/nostr/src/event/mod.rs index 3b213429f..aca4f2cc1 100644 --- a/crates/nostr/src/event/mod.rs +++ b/crates/nostr/src/event/mod.rs @@ -261,7 +261,7 @@ impl Event { /// <https://github.com/nostr-protocol/nips/blob/master/70.md> #[inline] pub fn is_protected(&self) -> bool { - self.tags.find_standardized(TagKind::Protected).is_some() + self.tags.iter().any(|t| t.is_protected()) } } diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index d0a77b5c1..b45fe5752 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -117,10 +117,6 @@ pub enum TagKind<'a> { PollType, /// Preimage Preimage, - /// Protected event - /// - /// <https://github.com/nostr-protocol/nips/blob/master/70.md> - Protected, /// Proxy Proxy, /// PublishedAt @@ -357,7 +353,6 @@ impl<'a> TagKind<'a> { Self::Payload => "payload", Self::PollType => "polltype", Self::Preimage => "preimage", - Self::Protected => "-", Self::Proxy => "proxy", Self::PublishedAt => "published_at", Self::Recording => "recording", @@ -396,7 +391,6 @@ impl fmt::Display for TagKind<'_> { impl<'a> From<&'a str> for TagKind<'a> { fn from(kind: &'a str) -> Self { match kind { - "-" => Self::Protected, "aes-256-gcm" => Self::Aes256Gcm, "alt" => Self::Alt, "amount" => Self::Amount, @@ -490,9 +484,6 @@ mod tests { #[test] fn test_de_serialization() { - assert_eq!(TagKind::from("-"), TagKind::Protected); - assert_eq!(TagKind::Protected.as_str(), "-"); - assert_eq!(TagKind::from("aes-256-gcm"), TagKind::Aes256Gcm); assert_eq!(TagKind::Aes256Gcm.as_str(), "aes-256-gcm"); @@ -562,7 +553,6 @@ mod tests { #[test] fn test_eq() { - assert_eq!(TagKind::Custom(Cow::from("-")), TagKind::Protected); assert_eq!(TagKind::Custom(Cow::from("p")), TagKind::p()); assert_eq!( TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)), diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 1aa99df4d..c2daa6bf7 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -33,6 +33,7 @@ use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip10::Marker; use crate::nips::nip40::Nip40Tag; use crate::nips::nip56::Report; +use crate::nips::nip70::Nip70Tag; use crate::types::Url; use crate::{ImageDimensions, PublicKey, RelayUrl, SingleLetterTag, Timestamp}; @@ -416,7 +417,7 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/70.md> #[inline] pub fn protected() -> Self { - Self::from_standardized(TagStandard::Protected) + Nip70Tag::Protected.to_tag() } /// A short human-readable plaintext summary of what that event is about @@ -475,7 +476,7 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/70.md> #[inline] pub fn is_protected(&self) -> bool { - matches!(self.standardized(), Some(TagStandard::Protected)) + self.buf == ["-"] } } diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 2aede5e9d..b80e6647f 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -237,10 +237,6 @@ pub enum TagStandard { /// /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> Repository(String), - /// Protected event - /// - /// <https://github.com/nostr-protocol/nips/blob/master/70.md> - Protected, /// A short human-readable plaintext summary of what that event is about /// /// <https://github.com/nostr-protocol/nips/blob/master/31.md> @@ -342,7 +338,6 @@ impl TagStandard { }); } TagKind::Encrypted => return Ok(Self::Encrypted), - TagKind::Protected => return Ok(Self::Protected), TagKind::Relays => { let urls: Vec<RelayUrl> = extract_relay_urls(tag)?; return Ok(Self::Relays(urls)); @@ -642,7 +637,6 @@ impl TagStandard { character: Alphabet::L, uppercase: false, }), - Self::Protected => TagKind::Protected, Self::Alt(..) => TagKind::Alt, Self::Web(..) => TagKind::Web, } @@ -898,7 +892,6 @@ impl From<TagStandard> for Vec<String> { } tag } - TagStandard::Protected => vec![tag_kind], TagStandard::Alt(summary) => vec![tag_kind, summary], TagStandard::Web(urls) => { let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); @@ -1395,8 +1388,6 @@ mod tests { #[test] fn test_tag_standard_serialization() { - assert_eq!(vec!["-"], TagStandard::Protected.to_vec()); - assert_eq!( vec!["alt", "something"], TagStandard::Alt(String::from("something")).to_vec() @@ -1849,8 +1840,6 @@ mod tests { #[test] fn test_tag_standard_parsing() { - assert_eq!(TagStandard::parse(&["-"]).unwrap(), TagStandard::Protected); - assert_eq!( TagStandard::parse(&["alt", "something"]).unwrap(), TagStandard::Alt(String::from("something")) diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index 91af58865..a534039a7 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -49,6 +49,7 @@ pub mod nip59; pub mod nip60; pub mod nip62; pub mod nip65; +pub mod nip70; pub mod nip73; pub mod nip88; pub mod nip90; diff --git a/crates/nostr/src/nips/nip70.rs b/crates/nostr/src/nips/nip70.rs new file mode 100644 index 000000000..3f007b872 --- /dev/null +++ b/crates/nostr/src/nips/nip70.rs @@ -0,0 +1,90 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-70: Protected Events +//! +//! <https://github.com/nostr-protocol/nips/blob/master/70.md> + +use alloc::string::String; +use alloc::vec; +use core::fmt; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +/// NIP-70 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-70 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/70.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip70Tag { + /// Protected event tag + /// + /// `["-"]` + Protected, +} + +impl TagCodec for Nip70Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + "-" => Ok(Self::Protected), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Protected => Tag::new(vec![String::from("-")]), + } + } +} + +impl_tag_codec_conversions!(Nip70Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_standardized_protected_tag() { + let tag = vec!["-"]; + let parsed = Nip70Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip70Tag::Protected); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 542c9459c..5f32cb80f 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -72,6 +72,7 @@ pub use crate::nips::nip59::{self, *}; pub use crate::nips::nip60::{self, *}; pub use crate::nips::nip62::{self, *}; pub use crate::nips::nip65::{self, *}; +pub use crate::nips::nip70::{self, *}; pub use crate::nips::nip73::{self, *}; pub use crate::nips::nip88::{self, *}; pub use crate::nips::nip90::{self, *}; From 46050aa0ff32dd0be0973a1c508141844b0babbd Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Wed, 22 Apr 2026 08:39:11 +0200 Subject: [PATCH 13/40] nostr: rework NIP-62 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 4 +- crates/nostr/src/event/tag/mod.rs | 10 -- crates/nostr/src/event/tag/standard.rs | 24 +-- crates/nostr/src/nips/nip62.rs | 199 +++++++++++++++++++++- database/nostr-lmdb/src/store/lmdb/mod.rs | 17 +- database/nostr-memory/src/store.rs | 17 +- database/nostr-sqlite/src/store.rs | 17 +- 7 files changed, 226 insertions(+), 62 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 4d39d7b49..9125278f1 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -612,7 +612,7 @@ impl EventBuilder { match target { VanishTarget::AllRelays => { - builder = builder.tag(Tag::all_relays()); + builder = builder.tag(Nip62Tag::AllRelays.to_tag()); } VanishTarget::Relays(list) => { // Check if the list is empty @@ -621,7 +621,7 @@ impl EventBuilder { return Err(Error::EmptyTags); } - builder = builder.tags(list.into_iter().map(Tag::relay)); + builder = builder.tags(list.into_iter().map(Nip62Tag::Relay).map(Into::into)); } } diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index c2daa6bf7..2c8b3812b 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -358,16 +358,6 @@ impl Tag { Self::from_standardized(TagStandard::Relays(urls.into_iter().collect())) } - /// All relays - /// - /// JSON: `["relay", "ALL_RELAYS"]` - /// - /// <https://github.com/nostr-protocol/nips/blob/master/62.md> - #[inline] - pub fn all_relays() -> Self { - Self::from_standardized(TagStandard::AllRelays) - } - /// Compose `["t", "<hashtag>"]` tag /// /// This will convert the hashtag to lowercase. diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index b80e6647f..2869f3649 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -30,8 +30,6 @@ use crate::{ Alphabet, Event, ImageDimensions, JsonUtil, Kind, PublicKey, SingleLetterTag, Timestamp, }; -const ALL_RELAYS: &str = "ALL_RELAYS"; - /// Standardized tag #[allow(deprecated)] #[allow(missing_docs)] @@ -107,10 +105,6 @@ pub enum TagStandard { }, Relay(RelayUrl), Relays(Vec<RelayUrl>), - /// All relays tag - /// - /// <https://github.com/nostr-protocol/nips/blob/master/62.md> - AllRelays, /// Poll end timestamp /// /// <https://github.com/nostr-protocol/nips/blob/master/88.md> @@ -372,13 +366,7 @@ impl TagStandard { uppercase: false, }) => Ok(Self::AbsoluteURL(Url::parse(tag_1)?)), TagKind::Dependency => Ok(Self::Dependency(tag_1.to_string())), - TagKind::Relay => { - if tag_1 == ALL_RELAYS { - Ok(Self::AllRelays) - } else { - Ok(Self::Relay(RelayUrl::parse(tag_1)?)) - } - } + TagKind::Relay => Ok(Self::Relay(RelayUrl::parse(tag_1)?)), TagKind::Extension => Ok(Self::Extension(tag_1.to_string())), TagKind::License => Ok(Self::License(tag_1.to_string())), // TODO: depending on the event kind, handle the tag in the right way. @@ -567,7 +555,7 @@ impl TagStandard { uppercase: *uppercase, }) } - Self::Relay(..) | Self::AllRelays => TagKind::Relay, + Self::Relay(..) => TagKind::Relay, Self::PollEndsAt(..) => nip88::ENDS_AT_TAG_KIND, Self::PollOption { .. } => TagKind::Option, Self::PollResponse(..) => TagKind::Response, @@ -747,7 +735,6 @@ impl From<TagStandard> for Vec<String> { TagStandard::Kind { kind, .. } => vec![tag_kind, kind.to_string()], TagStandard::Nip73Kind { kind, .. } => vec![tag_kind, kind.to_string()], TagStandard::Relay(url) => vec![tag_kind, url.to_string()], - TagStandard::AllRelays => vec![tag_kind, ALL_RELAYS.to_string()], TagStandard::PollEndsAt(ends_at) => vec![tag_kind, ends_at.to_string()], TagStandard::PollOption(opt) => vec![tag_kind, opt.id, opt.text], TagStandard::PollResponse(response) => vec![tag_kind, response], @@ -1776,8 +1763,6 @@ mod tests { TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()).to_vec() ); - assert_eq!(vec!["relay", "ALL_RELAYS"], TagStandard::AllRelays.to_vec()); - assert_eq!( vec!["lnurl", "lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp"], TagStandard::Lnurl(String::from("lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp")).to_vec(), @@ -2281,11 +2266,6 @@ mod tests { TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()) ); - assert_eq!( - TagStandard::parse(&["relay", "ALL_RELAYS"]).unwrap(), - TagStandard::AllRelays - ); - assert_eq!( TagStandard::parse(&[ "relays", diff --git a/crates/nostr/src/nips/nip62.rs b/crates/nostr/src/nips/nip62.rs index 26735af86..7226843a0 100644 --- a/crates/nostr/src/nips/nip62.rs +++ b/crates/nostr/src/nips/nip62.rs @@ -6,9 +6,48 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/62.md> +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; +use core::fmt; -use crate::RelayUrl; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, RelayUrl}; + +const RELAY: &str = "relay"; +const ALL_RELAYS: &str = "ALL_RELAYS"; + +/// NIP-70 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} /// Request to Vanish target #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -40,3 +79,161 @@ impl VanishTarget { Self::AllRelays } } + +/// Standardized NIP-62 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/62.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip62Tag { + /// Global Request to Vanish + /// + /// `["relay", "ALL_RELAYS"]` + AllRelays, + /// Request to Vanish from Relay + /// + /// `["relay", "<relay-url>"]` + Relay(RelayUrl), +} + +impl TagCodec for Nip62Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + RELAY => parse_relay_tag(iter), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::AllRelays => Tag::new(vec![String::from(RELAY), String::from(ALL_RELAYS)]), + Self::Relay(relay) => Tag::new(vec![String::from(RELAY), relay.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip62Tag); + +fn parse_relay_tag<T, S>(mut iter: T) -> Result<Nip62Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let relay_url: S = iter.next().ok_or(TagCodecError::Missing("relay URL"))?; + + match relay_url.as_ref() { + ALL_RELAYS => Ok(Nip62Tag::AllRelays), + other => { + let relay_url: RelayUrl = RelayUrl::parse(other)?; + Ok(Nip62Tag::Relay(relay_url)) + } + } +} + +/// Check whether a NIP-62 request to vanish applies to the given relay. +/// +/// Returns `true` if the event is a `kind:62` vanish request and it contains +/// either: +/// - an `["relay", "ALL_RELAYS"]` tag, or +/// - an `["relay", "<relay-url>"]` tag matching `relay_url`. +/// +/// Returns `false` for events with a different kind or for events that do not +/// target the given relay. +pub fn is_valid_vanish_request_for_relay(tags: &[Tag], relay_url: Option<&RelayUrl>) -> bool { + tags.iter().any(|tag| match Nip62Tag::try_from(tag) { + Ok(Nip62Tag::AllRelays) => true, + Ok(Nip62Tag::Relay(relay)) => Some(&relay) == relay_url, + _ => false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(feature = "std")] + use crate::{EventBuilder, PublicKey}; + + #[test] + fn test_standardized_relay_tag() { + let tag = vec!["relay", "wss://relay.damus.io"]; + let parsed = Nip62Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip62Tag::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_all_relays_tag() { + let tag = vec!["relay", "ALL_RELAYS"]; + let parsed = Nip62Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip62Tag::AllRelays); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + #[cfg(feature = "std")] + fn test_is_valid_vanish_request_for_relay() { + let relay_a = RelayUrl::parse("wss://relay.a.com").unwrap(); + let relay_b = RelayUrl::parse("wss://relay.b.com").unwrap(); + + let all_relays = EventBuilder::request_vanish(VanishTarget::all_relays()) + .unwrap() + .build( + PublicKey::from_hex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + ); + + assert!(is_valid_vanish_request_for_relay( + all_relays.tags.as_slice(), + Some(&relay_a) + )); + assert!(is_valid_vanish_request_for_relay( + all_relays.tags.as_slice(), + Some(&relay_b) + )); + + let single_relay = EventBuilder::request_vanish(VanishTarget::relay(relay_a.clone())) + .unwrap() + .build( + PublicKey::from_hex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + ); + + assert!(is_valid_vanish_request_for_relay( + single_relay.tags.as_slice(), + Some(&relay_a) + )); + assert!(!is_valid_vanish_request_for_relay( + single_relay.tags.as_slice(), + Some(&relay_b) + )); + + let other_kind = EventBuilder::text_note("hello").build( + PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .unwrap(), + ); + + assert!(!is_valid_vanish_request_for_relay( + other_kind.tags.as_slice(), + Some(&relay_a) + )); + } +} diff --git a/database/nostr-lmdb/src/store/lmdb/mod.rs b/database/nostr-lmdb/src/store/lmdb/mod.rs index e0119deb2..b71db3da8 100644 --- a/database/nostr-lmdb/src/store/lmdb/mod.rs +++ b/database/nostr-lmdb/src/store/lmdb/mod.rs @@ -505,15 +505,14 @@ impl Lmdb { } // Handle request to vanish - if self.options.process_nip62 && event.kind == Kind::RequestToVanish { - let is_targeted = event.tags.filter_standardized(TagKind::Relay).any(|tag| { - matches!(tag, TagStandard::AllRelays) - || matches!(tag, TagStandard::Relay(ref relay) if Some(relay) == self.options.relay_url.as_ref()) - }); - - if is_targeted { - self.handle_request_to_vanish(txn, &event.pubkey)?; - } + if self.options.process_nip62 + && event.kind == Kind::RequestToVanish + && nip62::is_valid_vanish_request_for_relay( + event.tags.as_slice(), + self.options.relay_url.as_ref(), + ) + { + self.handle_request_to_vanish(txn, &event.pubkey)?; } self.store(txn, fbb, event)?; diff --git a/database/nostr-memory/src/store.rs b/database/nostr-memory/src/store.rs index 8d40374f1..f915ca441 100644 --- a/database/nostr-memory/src/store.rs +++ b/database/nostr-memory/src/store.rs @@ -298,15 +298,14 @@ impl MemoryStore { to_discard.extend(self.internal_query_by_kind_and_author(params).map(|e| e.id)); } } - } else if self.options.process_nip62 && event.kind == Kind::RequestToVanish { - let is_targeted = event.tags.filter_standardized(TagKind::Relay).any(|tag| { - matches!(tag, TagStandard::AllRelays) - || matches!(tag, TagStandard::Relay(ref relay) if Some(relay) == self.options.relay_url.as_ref()) - }); - - if is_targeted { - self.handle_request_to_vanish(&event.pubkey); - } + } else if self.options.process_nip62 + && event.kind == Kind::RequestToVanish + && nip62::is_valid_vanish_request_for_relay( + event.tags.as_slice(), + self.options.relay_url.as_ref(), + ) + { + self.handle_request_to_vanish(&event.pubkey); } // Remove events diff --git a/database/nostr-sqlite/src/store.rs b/database/nostr-sqlite/src/store.rs index 87ede43b8..d4889e5b7 100644 --- a/database/nostr-sqlite/src/store.rs +++ b/database/nostr-sqlite/src/store.rs @@ -279,15 +279,14 @@ impl NostrSqlite { } } - if options.process_nip62 && event.kind == Kind::RequestToVanish { - let is_targeted = event.tags.filter_standardized(TagKind::Relay).any(|tag| { - matches!(tag, TagStandard::AllRelays) - || matches!(tag, TagStandard::Relay(ref relay) if Some(relay) == options.relay_url.as_ref()) - }); - - if is_targeted { - Self::handle_request_to_vanish(&tx, &event.pubkey)?; - } + if options.process_nip62 + && event.kind == Kind::RequestToVanish + && nip62::is_valid_vanish_request_for_relay( + event.tags.as_slice(), + options.relay_url.as_ref(), + ) + { + Self::handle_request_to_vanish(&tx, &event.pubkey)?; } // Insert event first From f8c61155ab91f08d0cac609933d6c929bdf311d4 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Thu, 30 Apr 2026 08:01:06 +0200 Subject: [PATCH 14/40] nostr: rework NIP-10 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 84 +++--- crates/nostr/src/event/tag/list.rs | 3 - crates/nostr/src/event/tag/mod.rs | 23 -- crates/nostr/src/event/tag/standard.rs | 348 +------------------------ crates/nostr/src/nips/nip10.rs | 322 ++++++++++++++++++++++- crates/nostr/src/nips/nip25.rs | 21 +- 6 files changed, 383 insertions(+), 418 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 9125278f1..04f838019 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -411,7 +411,7 @@ impl EventBuilder { content: S, reply_to: &Event, root: Option<&Event>, - relay_url: Option<RelayUrl>, + relay_hint: Option<RelayUrl>, ) -> Self where S: Into<String>, @@ -422,35 +422,41 @@ impl EventBuilder { Some(root) => { // Check if root event is different from reply event if root.id != reply_to.id { - tags.push(Tag::from_standardized(TagStandard::Event { - event_id: reply_to.id, - relay_url: relay_url.clone(), - marker: Some(Marker::Reply), - public_key: Some(reply_to.pubkey), - uppercase: false, - })); + tags.push( + Nip10Tag::Event { + id: reply_to.id, + relay_hint: relay_hint.clone(), + marker: Some(Marker::Reply), + public_key: Some(reply_to.pubkey), + } + .to_tag(), + ); tags.push(Tag::public_key(reply_to.pubkey)); } // ID and author - tags.push(Tag::from_standardized(TagStandard::Event { - event_id: root.id, - relay_url, - marker: Some(Marker::Root), - public_key: Some(root.pubkey), - uppercase: false, - })); + tags.push( + Nip10Tag::Event { + id: root.id, + relay_hint, + marker: Some(Marker::Root), + public_key: Some(root.pubkey), + } + .to_tag(), + ); tags.push(Tag::public_key(root.pubkey)); } // No root tag, add only reply_to tags None => { - tags.push(Tag::from_standardized(TagStandard::Event { - event_id: reply_to.id, - relay_url, - marker: Some(Marker::Reply), - public_key: Some(reply_to.pubkey), - uppercase: false, - })); + tags.push( + Nip10Tag::Event { + id: reply_to.id, + relay_hint, + marker: Some(Marker::Reply), + public_key: Some(reply_to.pubkey), + } + .to_tag(), + ); tags.push(Tag::public_key(reply_to.pubkey)); } } @@ -530,9 +536,6 @@ impl EventBuilder { TagStandard::Event { event_id, relay_url, - marker: None, - public_key: None, - uppercase: false, }, )]), ) @@ -690,15 +693,15 @@ impl EventBuilder { relay_url: Option<RelayUrl>, metadata: &Metadata, ) -> Self { - Self::new(Kind::ChannelMetadata, metadata.as_json()).tags([Tag::from_standardized( - TagStandard::Event { - event_id: channel_id, - relay_url, + Self::new(Kind::ChannelMetadata, metadata.as_json()).tag( + Nip10Tag::Event { + id: channel_id, + relay_hint: relay_url, marker: None, public_key: None, - uppercase: false, - }, - )]) + } + .to_tag(), + ) } /// Channel message @@ -709,15 +712,15 @@ impl EventBuilder { where S: Into<String>, { - Self::new(Kind::ChannelMessage, content).tags([Tag::from_standardized( - TagStandard::Event { - event_id: channel_id, - relay_url: Some(relay_url), + Self::new(Kind::ChannelMessage, content).tag( + Nip10Tag::Event { + id: channel_id, + relay_hint: Some(relay_url), marker: Some(Marker::Root), public_key: None, - uppercase: false, - }, - )]) + } + .to_tag(), + ) } /// Hide message @@ -1123,9 +1126,6 @@ impl EventBuilder { let badge_award_event_tag: Tag = Tag::from_standardized(TagStandard::Event { event_id: badge_award_event.id, relay_url: relay_url.clone(), - marker: None, - public_key: None, - uppercase: false, }); tags.extend_from_slice(&[a_tag.clone(), badge_award_event_tag]); } diff --git a/crates/nostr/src/event/tag/list.rs b/crates/nostr/src/event/tag/list.rs index 5804c5d86..71b6fa904 100644 --- a/crates/nostr/src/event/tag/list.rs +++ b/crates/nostr/src/event/tag/list.rs @@ -620,9 +620,6 @@ mod tests { let long_e_tag_2 = Tag::from_standardized(TagStandard::Event { event_id: event2, relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: None, - public_key: None, - uppercase: false, }); let empty_list: Vec<String> = Vec::new(); diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 2c8b3812b..28173cf2c 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -30,7 +30,6 @@ pub use self::list::Tags; pub use self::standard::TagStandard; use super::id::EventId; use crate::nips::nip01::{Coordinate, Nip01Tag}; -use crate::nips::nip10::Marker; use crate::nips::nip40::Nip40Tag; use crate::nips::nip56::Report; use crate::nips::nip70::Nip70Tag; @@ -439,28 +438,6 @@ impl Tag { Self::new(buf) } - /// Check if is a standard event tag with `root` marker - pub fn is_root(&self) -> bool { - matches!( - self.standardized(), - Some(TagStandard::Event { - marker: Some(Marker::Root), - .. - }) - ) - } - - /// Check if is a standard event tag with `reply` marker - pub fn is_reply(&self) -> bool { - matches!( - self.standardized(), - Some(TagStandard::Event { - marker: Some(Marker::Reply), - .. - }) - ) - } - /// Check if it's a protected event tag /// /// <https://github.com/nostr-protocol/nips/blob/master/70.md> diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 2869f3649..c123ce2f4 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -15,7 +15,6 @@ use secp256k1::schnorr::Signature; use super::{Error, TagKind}; use crate::event::id::EventId; use crate::nips::nip01::Coordinate; -use crate::nips::nip10::Marker; use crate::nips::nip39::Identity; use crate::nips::nip48::Protocol; use crate::nips::nip53::{LiveEventMarker, LiveEventStatus}; @@ -36,16 +35,9 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum TagStandard { /// Event - /// - /// <https://github.com/nostr-protocol/nips/blob/master/01.md> and <https://github.com/nostr-protocol/nips/blob/master/10.md> Event { event_id: EventId, relay_url: Option<RelayUrl>, - marker: Option<Marker>, - /// Should be the public key of the author of the referenced event - public_key: Option<PublicKey>, - /// Whether the tag is an uppercase or not - uppercase: bool, }, /// Report event /// @@ -477,9 +469,6 @@ impl TagStandard { Self::Event { event_id, relay_url: None, - marker: None, - public_key: None, - uppercase: false, } } @@ -495,24 +484,12 @@ impl TagStandard { } } - /// Check if tag is an event `reply` - #[inline] - pub fn is_reply(&self) -> bool { - matches!( - self, - Self::Event { - marker: Some(Marker::Reply), - .. - } - ) - } - /// Get tag kind pub fn kind(&self) -> TagKind<'_> { match self { - Self::Event { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { + Self::Event { .. } => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::E, - uppercase: *uppercase, + uppercase: false, }), Self::EventReport(..) => TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E)), Self::PublicKey { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { @@ -644,30 +621,15 @@ impl From<TagStandard> for Vec<String> { TagStandard::Event { event_id, relay_url, - marker, - public_key, .. } => { - // ["e", <event-id>, <relay-url>, <marker>, <pubkey>] - // <relay-url>, <marker> and <pubkey> are optional - // <relay-url>, if empty, may be set to "" (if there are additional fields later) - // <marker> is optional and if present is one of "reply", "root", or "mention" (so not an empty string) + // ["e", <event-id>, <relay-url>] let mut tag: Vec<String> = vec![tag_kind, event_id.to_hex()]; - // Check if <relay-url> exists or if there are additional fields after - match (relay_url, marker.is_some() || public_key.is_some()) { - (Some(relay_url), ..) => tag.push(relay_url.to_string()), - (None, true) => tag.push(String::new()), - (None, false) => {} - } - - if let Some(marker) = marker { - tag.push(marker.to_string()); - } - - if let Some(public_key) = public_key { - tag.push(public_key.to_string()); + // Check if <relay-url> exists + if let Some(relay_url) = relay_url { + tag.push(relay_url.to_string()); } tag @@ -923,15 +885,9 @@ where let event_id: EventId = EventId::from_hex(tag[1].as_ref())?; - // Try getting indexes 2, 3 and 4 and make sure they are not empty. - // If these are empty, they are handled as None. + // Try getting indexes 2 and make sure it is not empty. + // If this is empty, it is handled as None. let tag_2: Option<&str> = tag.get(2).map(|r| r.as_ref()).filter(|r| !r.is_empty()); - // "mention" is a removed marker from NIP-10 - let tag_3: Option<&str> = tag - .get(3) - .map(|r| r.as_ref()) - .filter(|r| !r.is_empty() && *r != "mention"); - let tag_4: Option<&str> = tag.get(4).map(|r| r.as_ref()).filter(|r| !r.is_empty()); // Check if it's a report if let Some(tag_2) = tag_2 { @@ -951,42 +907,9 @@ where None => None, }; - // Check if 3rd arg is a marker or a public key - let (marker, public_key) = match (tag_3, tag_4) { - (Some(marker), Some(public_key)) => { - // Parse marker and public key - // NOTE: we already checked above if the strings are empty - let marker: Marker = Marker::from_str(marker)?; - let public_key: PublicKey = PublicKey::from_hex(public_key)?; - - (Some(marker), Some(public_key)) - } - (Some(marker), None) => { - // NOTE: we already checked above if the strings are empty - // Try parse 3rd arg as a marked (NIP-10) - match Marker::from_str(marker) { - Ok(marker) => (Some(marker), None), - // It's not a marker, try to parse it as a public key (NIP-01) - Err(..) => { - let public_key: PublicKey = PublicKey::from_hex(marker)?; - (None, Some(public_key)) - } - } - } - (None, Some(public_key)) => { - // NOTE: we already checked above if the string is empty - let public_key = PublicKey::from_hex(public_key)?; - (None, Some(public_key)) - } - (None, None) => (None, None), - }; - Ok(TagStandard::Event { event_id, relay_url, - marker, - public_key, - uppercase, }) } @@ -1265,55 +1188,6 @@ mod tests { use super::*; use crate::nips::nip39::ExternalIdentity; - // Issue: https://gitworkshop.dev/yukikishimoto.com/nostr/issues/note15xl8ae8dnmt26adfw6ec8gshxxs242vrvsa3v36ctwq2x9gglkustlxlwa - #[test] - fn tag_e_tag_with_blank_values() { - let hex = "a3ce0a22c5c25e5a41a17004d38ed2aa8f815dda918c92400c6b611c41acbc78"; - let id = EventId::from_hex(hex).unwrap(); - - let result = TagStandard::parse(&["e", hex, "", "", ""]).unwrap(); - assert_eq!( - result, - TagStandard::Event { - event_id: id, - relay_url: None, - marker: None, - public_key: None, - uppercase: false - } - ) - } - - #[test] - fn test_tag_standard_is_reply() { - let tag = TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()); - assert!(!tag.is_reply()); - - let tag = TagStandard::Event { - event_id: EventId::from_hex( - "2be17aa3031bdcb006f0fce80c146dea9c1c0268b0af2398bb673365c6444d45", - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Reply), - public_key: None, - uppercase: false, - }; - assert!(tag.is_reply()); - - let tag = TagStandard::Event { - event_id: EventId::from_hex( - "2be17aa3031bdcb006f0fce80c146dea9c1c0268b0af2398bb673365c6444d45", - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Root), - public_key: None, - uppercase: false, - }; - assert!(!tag.is_reply()); - } - #[test] fn test_nip73_kind() { let tag = TagStandard::Nip73Kind { @@ -1454,9 +1328,6 @@ mod tests { ) .unwrap(), relay_url: None, - marker: None, - public_key: None, - uppercase: false, } .to_vec() ); @@ -1473,9 +1344,6 @@ mod tests { ) .unwrap(), relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: None, - public_key: None, - uppercase: false, } .to_vec() ); @@ -1687,77 +1555,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "reply" - ], - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Reply), - public_key: None, - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "0000000000000000000000000000000000000000000000000000000000000001", - "", - "root", - "0000000000000000000000000000000000000000000000000000000000000001", - ], - TagStandard::Event { - event_id: EventId::from_hex( - "0000000000000000000000000000000000000000000000000000000000000001" - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Root), - public_key: Some( - PublicKey::parse( - "0000000000000000000000000000000000000000000000000000000000000001" - ) - .unwrap() - ), - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "0000000000000000000000000000000000000000000000000000000000000001", - "", - "0000000000000000000000000000000000000000000000000000000000000001", - ], - TagStandard::Event { - event_id: EventId::from_hex( - "0000000000000000000000000000000000000000000000000000000000000001" - ) - .unwrap(), - relay_url: None, - marker: None, - public_key: Some( - PublicKey::parse( - "0000000000000000000000000000000000000000000000000000000000000001" - ) - .unwrap() - ), - uppercase: false, - } - .to_vec() - ); - assert_eq!( vec!["relay", "wss://relay.damus.io"], TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()).to_vec() @@ -1863,23 +1660,6 @@ mod tests { ) ); - assert_eq!( - TagStandard::parse(&[ - "E", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ]), - Ok(TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: None, - public_key: None, - uppercase: true - }) - ); - assert_eq!( TagStandard::parse(&["content-warning", "reason"]).unwrap(), TagStandard::ContentWarning { @@ -1953,25 +1733,6 @@ mod tests { } ); - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: None, - public_key: None, - uppercase: false, - } - ); - assert_eq!( TagStandard::parse(&[ "e", @@ -1985,9 +1746,6 @@ mod tests { ) .unwrap(), relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: None, - public_key: None, - uppercase: false, } ); @@ -2190,77 +1948,6 @@ mod tests { } ); - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "reply" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Reply), - public_key: None, - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "reply", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: Some(Marker::Reply), - public_key: Some( - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ), - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - marker: None, - public_key: Some( - PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ), - uppercase: false, - } - ); - assert_eq!( TagStandard::parse(&["relay", "wss://relay.damus.io"]).unwrap(), TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()) @@ -2356,23 +2043,4 @@ mod tests { assert!(TagStandard::parse(&["t", "nostr"]).is_ok()); assert!(TagStandard::parse(&["t", "سلام"]).is_ok()); } - - #[test] - fn e_tag_with_mention_marker() { - let hex = "19bb195b83fd26db217b6feebb444de4808d90eb4375c31c75ba5bb5c5c10cfc"; - let id = EventId::from_hex(hex).unwrap(); - - let result = TagStandard::parse(&["e", hex, "", "mention"]); - - assert_eq!( - result, - Ok(TagStandard::Event { - event_id: id, - relay_url: None, - marker: None, - public_key: None, - uppercase: false - }) - ); - } } diff --git a/crates/nostr/src/nips/nip10.rs b/crates/nostr/src/nips/nip10.rs index c4f931b0f..943f1c760 100644 --- a/crates/nostr/src/nips/nip10.rs +++ b/crates/nostr/src/nips/nip10.rs @@ -2,16 +2,36 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP10: Conventions for clients' use of `e` and `p` tags in text events +//! NIP-10: Conventions for clients' use of `e` and `p` tags in text events //! //! <https://github.com/nostr-protocol/nips/blob/master/10.md> +use alloc::string::{String, ToString}; +use alloc::vec::Vec; use core::fmt; use core::str::FromStr; +use super::util::{ + take_and_parse_optional_public_key, take_and_parse_optional_relay_url, take_event_id, +}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::event::{self}; +use crate::types::url; +use crate::{EventId, PublicKey, RelayUrl, key}; + +const EVENT: &str = "e"; + /// NIP10 error -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq)] pub enum Error { + /// Keys error + Keys(key::Error), + /// Event error + Event(event::Error), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), /// Invalid marker InvalidMarker, } @@ -21,11 +41,39 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Keys(e) => e.fmt(f), + Self::Event(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidMarker => f.write_str("invalid marker"), } } } +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Marker /// /// <https://github.com/nostr-protocol/nips/blob/master/10.md> @@ -57,3 +105,273 @@ impl FromStr for Marker { } } } + +/// Standardized NIP-10 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/10.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip10Tag { + /// `e` tag + Event { + /// Event ID + id: EventId, + /// Relay hint + relay_hint: Option<RelayUrl>, + /// Event marker + marker: Option<Marker>, + /// Public key hint + public_key: Option<PublicKey>, + }, +} + +impl Nip10Tag { + /// Check if this tag is marked as a root reference. + #[inline] + pub fn is_root(&self) -> bool { + matches!( + self, + Self::Event { + marker: Some(Marker::Root), + .. + } + ) + } + + /// Check if this tag is marked as a reply reference. + #[inline] + pub fn is_reply(&self) -> bool { + matches!( + self, + Self::Event { + marker: Some(Marker::Reply), + .. + } + ) + } +} + +impl TagCodec for Nip10Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + EVENT => { + let (id, relay_hint, marker, public_key) = parse_e_tag(iter)?; + Ok(Self::Event { + id, + relay_hint, + marker, + public_key, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Event { + id, + relay_hint, + marker, + public_key, + } => { + // ["e", <event-id>, <relay-url>, <marker>, <pubkey>] + // <relay-url>, <marker> and <pubkey> are optional + // <relay-url>, if empty, may be set to "" (if there are additional fields later) + // <marker> is optional and if present is one of "reply" or "root" (so not an empty string) + + let mut tag: Vec<String> = Vec::with_capacity( + 2 + relay_hint.is_some() as usize + + marker.is_some() as usize + + public_key.is_some() as usize, + ); + + tag.push(String::from(EVENT)); + tag.push(id.to_hex()); + + // Check if relay hint exists or if there are additional fields after + match (relay_hint, marker.is_some() || public_key.is_some()) { + (Some(relay_hint), ..) => tag.push(relay_hint.to_string()), + (None, true) => tag.push(String::new()), + (None, false) => {} + } + + match (marker, public_key.is_some()) { + (Some(marker), _) => tag.push(marker.to_string()), + (None, true) => tag.push(String::new()), + (None, false) => {} + } + + if let Some(public_key) = public_key { + tag.push(public_key.to_hex()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip10Tag); + +#[allow(clippy::type_complexity)] +fn parse_e_tag<T, S>( + mut iter: T, +) -> Result<(EventId, Option<RelayUrl>, Option<Marker>, Option<PublicKey>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + + let marker: Option<Marker> = match iter.next() { + Some(marker) => { + let marker: &str = marker.as_ref(); + + if !marker.is_empty() && marker != "mention" { + Some(Marker::from_str(marker)?) + } else { + None + } + } + _ => None, + }; + + let public_key: Option<PublicKey> = take_and_parse_optional_public_key(&mut iter)?; + + Ok((id, relay_hint, marker, public_key)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::PublicKey; + + #[test] + fn test_parse_empty_tag() { + let tag: Vec<String> = Vec::new(); + let err = Nip10Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::missing_tag_kind())); + } + + #[test] + fn test_non_existing_tag() { + let tag = vec!["p"]; + let err = Nip10Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Unknown)); + } + + #[test] + fn test_standardized_e_tag() { + let relay_hint = RelayUrl::parse("wss://relay.example.com").unwrap(); + let public_key = + PublicKey::from_hex("aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4") + .unwrap(); + let tag = vec![ + String::from("e"), + EventId::all_zeros().to_hex(), + relay_hint.to_string(), + String::from("root"), + public_key.to_hex(), + ]; + let parsed = Nip10Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip10Tag::Event { + id: EventId::all_zeros(), + relay_hint: Some(relay_hint), + marker: Some(Marker::Root), + public_key: Some(public_key), + } + ); + assert!(parsed.is_root()); + assert!(!parsed.is_reply()); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_positional_e_tag() { + let tag = vec![String::from("e"), EventId::all_zeros().to_hex()]; + let parsed = Nip10Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip10Tag::Event { + id: EventId::all_zeros(), + relay_hint: None, + marker: None, + public_key: None, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_e_tag_with_empty_marker() { + let public_key = + PublicKey::from_hex("aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4") + .unwrap(); + let tag = vec![ + String::from("e"), + EventId::all_zeros().to_hex(), + String::from("wss://relay.example.com"), + String::new(), + public_key.to_hex(), + ]; + let parsed = Nip10Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip10Tag::Event { + id: EventId::all_zeros(), + relay_hint: Some(RelayUrl::parse("wss://relay.example.com").unwrap()), + marker: None, + public_key: Some(public_key), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_e_tag_without_marker_is_invalid() { + let public_key = + PublicKey::from_hex("aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4") + .unwrap(); + let tag = vec![ + String::from("e"), + EventId::all_zeros().to_hex(), + String::from("wss://relay.example.com"), + public_key.to_hex(), + ]; + let err = Nip10Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::InvalidMarker); + } + + #[test] + fn test_e_tag_with_mention_marker() { + let hex = "19bb195b83fd26db217b6feebb444de4808d90eb4375c31c75ba5bb5c5c10cfc"; + let id = EventId::from_hex(hex).unwrap(); + + let result = Nip10Tag::parse(["e", hex, "", "mention"]); + + assert_eq!( + result, + Ok(Nip10Tag::Event { + id, + relay_hint: None, + marker: None, + public_key: None, + }) + ); + } +} diff --git a/crates/nostr/src/nips/nip25.rs b/crates/nostr/src/nips/nip25.rs index e84602a63..8e7d7aaa0 100644 --- a/crates/nostr/src/nips/nip25.rs +++ b/crates/nostr/src/nips/nip25.rs @@ -7,7 +7,9 @@ //! <https://github.com/nostr-protocol/nips/blob/master/25.md> use super::nip01::Coordinate; -use crate::{Event, EventId, Kind, PublicKey, RelayUrl, Tag, TagStandard, Tags}; +use super::nip22::Nip22Tag; +use crate::event::tag::{Tag, TagCodec, TagStandard, Tags}; +use crate::{Event, EventId, Kind, PublicKey, RelayUrl}; /// Reaction target #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -43,13 +45,16 @@ impl ReactionTarget { // Serialization order: keep the `e` and `a` tags together, followed by the `p` and other tags. - tags.push(Tag::from_standardized(TagStandard::Event { - event_id: self.event_id, - relay_url: self.relay_hint.clone(), - public_key: Some(self.public_key), - marker: None, - uppercase: false, - })); + // TODO: replace with a dedicated NIP-25 tag + tags.push( + Nip22Tag::Event { + id: self.event_id, + relay_hint: self.relay_hint.clone(), + public_key: Some(self.public_key), + uppercase: false, + } + .to_tag(), + ); if let Some(coordinate) = self.coordinate { tags.push(Tag::coordinate(coordinate, self.relay_hint.clone())); From 9109767892a93e007a90d5f7e5dc9ab6fa649fcd Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Thu, 30 Apr 2026 09:38:15 +0200 Subject: [PATCH 15/40] nostr: rework NIP-94 tags Closes https://github.com/rust-nostr/nostr/issues/1320 Replaces https://github.com/rust-nostr/nostr/pull/1321 Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/kind.rs | 23 - crates/nostr/src/event/tag/standard.rs | 45 -- crates/nostr/src/nips/nip94.rs | 773 ++++++++++++++++++++----- crates/nostr/src/nips/util.rs | 35 +- rfs/nostr-blossom/src/bud01.rs | 4 +- 5 files changed, 668 insertions(+), 212 deletions(-) diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index b45fe5752..80d355b08 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -15,8 +15,6 @@ use crate::{Alphabet, SingleLetterTag}; /// Tag kind #[derive(Debug, Clone)] pub enum TagKind<'a> { - /// AES 256 GCM - Aes256Gcm, /// Human-readable plaintext summary of what that event is about /// /// <https://github.com/nostr-protocol/nips/blob/master/31.md> @@ -25,8 +23,6 @@ pub enum TagKind<'a> { Amount, /// Anonymous Anon, - /// Blurhash - Blurhash, /// Bolt11 invoice Bolt11, /// Challenge @@ -63,8 +59,6 @@ pub enum TagKind<'a> { Dependency, /// Description Description, - /// Size of the file in pixels - Dim, /// Emoji Emoji, /// Encrypted @@ -89,8 +83,6 @@ pub enum TagKind<'a> { License, /// Lnurl Lnurl, - /// Magnet - Magnet, /// Maintainers Maintainers, /// HTTP Method Request @@ -141,8 +133,6 @@ pub enum TagKind<'a> { Runtime, /// Server Server, - /// Size of the file in bytes - Size, /// Starts Starts, /// Status @@ -314,11 +304,9 @@ impl<'a> TagKind<'a> { /// Convert to `&str` pub fn as_str(&self) -> &str { match self { - Self::Aes256Gcm => "aes-256-gcm", Self::Alt => "alt", Self::Amount => "amount", Self::Anon => "anon", - Self::Blurhash => "blurhash", Self::Bolt11 => "bolt11", Self::BranchName => "branch-name", Self::Challenge => "challenge", @@ -329,7 +317,6 @@ impl<'a> TagKind<'a> { Self::CurrentParticipants => "current_participants", Self::Dependency => "dep", Self::Description => "description", - Self::Dim => "dim", Self::Emoji => "emoji", Self::Encrypted => "encrypted", Self::Ends => "ends", @@ -340,7 +327,6 @@ impl<'a> TagKind<'a> { Self::Image => "image", Self::License => "license", Self::Lnurl => "lnurl", - Self::Magnet => "magnet", Self::Maintainers => "maintainers", Self::MergeBase => "merge-base", Self::Method => "method", @@ -363,7 +349,6 @@ impl<'a> TagKind<'a> { Self::Response => "response", Self::Runtime => "runtime", Self::Server => "server", - Self::Size => "size", Self::Starts => "starts", Self::Status => "status", Self::Streaming => "streaming", @@ -391,11 +376,9 @@ impl fmt::Display for TagKind<'_> { impl<'a> From<&'a str> for TagKind<'a> { fn from(kind: &'a str) -> Self { match kind { - "aes-256-gcm" => Self::Aes256Gcm, "alt" => Self::Alt, "amount" => Self::Amount, "anon" => Self::Anon, - "blurhash" => Self::Blurhash, "bolt11" => Self::Bolt11, "branch-name" => Self::BranchName, "challenge" => Self::Challenge, @@ -406,7 +389,6 @@ impl<'a> From<&'a str> for TagKind<'a> { "current_participants" => Self::CurrentParticipants, "dep" => Self::Dependency, "description" => Self::Description, - "dim" => Self::Dim, "emoji" => Self::Emoji, "encrypted" => Self::Encrypted, "ends" => Self::Ends, @@ -416,7 +398,6 @@ impl<'a> From<&'a str> for TagKind<'a> { "image" => Self::Image, "license" => Self::License, "lnurl" => Self::Lnurl, - "magnet" => Self::Magnet, "maintainers" => Self::Maintainers, "merge-base" => Self::MergeBase, "method" => Self::Method, @@ -440,7 +421,6 @@ impl<'a> From<&'a str> for TagKind<'a> { "runtime" => Self::Runtime, "HEAD" => Self::Head, "server" => Self::Server, - "size" => Self::Size, "starts" => Self::Starts, "status" => Self::Status, "streaming" => Self::Streaming, @@ -484,9 +464,6 @@ mod tests { #[test] fn test_de_serialization() { - assert_eq!(TagKind::from("aes-256-gcm"), TagKind::Aes256Gcm); - assert_eq!(TagKind::Aes256Gcm.as_str(), "aes-256-gcm"); - assert_eq!(TagKind::from("alt"), TagKind::Alt); assert_eq!(TagKind::Alt.as_str(), "alt"); diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index c123ce2f4..627a4d535 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -149,17 +149,7 @@ pub enum TagStandard { Name(String), PublishedAt(Timestamp), Url(Url), - MimeType(String), - Aes256Gcm { - key: String, - iv: String, - }, Server(Url), - Sha256(Sha256Hash), - Size(usize), - Dim(ImageDimensions), - Magnet(String), - Blurhash(String), Streaming(Url), Recording(Url), Starts(Timestamp), @@ -345,14 +335,6 @@ impl TagStandard { character: Alphabet::G, uppercase: false, }) => Ok(Self::Geohash(tag_1.to_string())), - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::M, - uppercase: false, - }) => Ok(Self::MimeType(tag_1.to_string())), - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::X, - uppercase: false, - }) => Ok(Self::Sha256(Sha256Hash::from_str(tag_1)?)), TagKind::SingleLetter(SingleLetterTag { character: Alphabet::U, uppercase: false, @@ -384,8 +366,6 @@ impl TagStandard { TagKind::Lnurl => Ok(Self::Lnurl(tag_1.to_string())), TagKind::Name => Ok(Self::Name(tag_1.to_string())), TagKind::Url => Ok(Self::Url(Url::parse(tag_1)?)), - TagKind::Magnet => Ok(Self::Magnet(tag_1.to_string())), - TagKind::Blurhash => Ok(Self::Blurhash(tag_1.to_string())), TagKind::Streaming => Ok(Self::Streaming(Url::parse(tag_1)?)), TagKind::Recording => Ok(Self::Recording(Url::parse(tag_1)?)), TagKind::Starts => Ok(Self::Starts(Timestamp::from_str(tag_1)?)), @@ -409,7 +389,6 @@ impl TagStandard { TagKind::Request => Ok(Self::Request(Event::from_json(tag_1)?)), TagKind::Word => Ok(Self::Word(tag_1.to_string())), TagKind::Alt => Ok(Self::Alt(tag_1.to_string())), - TagKind::Dim => Ok(Self::Dim(ImageDimensions::from_str(tag_1)?)), _ => Err(Error::UnknownStandardizedTag), }; } @@ -435,10 +414,6 @@ impl TagStandard { Url::parse(tag_1)?, Some(ImageDimensions::from_str(tag_2)?), )), - TagKind::Aes256Gcm => Ok(Self::Aes256Gcm { - key: tag_1.to_string(), - iv: tag_2.to_string(), - }), TagKind::Proxy => Ok(Self::Proxy { id: tag_1.to_string(), protocol: Protocol::from(tag_2), @@ -560,20 +535,7 @@ impl TagStandard { Self::Repository(..) => TagKind::Repository, Self::Lnurl(..) => TagKind::Lnurl, Self::Url(..) => TagKind::Url, - Self::MimeType(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::M, - uppercase: false, - }), - Self::Aes256Gcm { .. } => TagKind::Aes256Gcm, Self::Server(..) => TagKind::Server, - Self::Sha256(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::X, - uppercase: false, - }), - Self::Size(..) => TagKind::Size, - Self::Dim(..) => TagKind::Dim, - Self::Magnet(..) => TagKind::Magnet, - Self::Blurhash(..) => TagKind::Blurhash, Self::Streaming(..) => TagKind::Streaming, Self::Recording(..) => TagKind::Recording, Self::Starts(..) => TagKind::Starts, @@ -777,14 +739,7 @@ impl From<TagStandard> for Vec<String> { TagStandard::Repository(repo) => vec![tag_kind, repo], TagStandard::Lnurl(lnurl) => vec![tag_kind, lnurl], TagStandard::Url(url) => vec![tag_kind, url.to_string()], - TagStandard::MimeType(mime) => vec![tag_kind, mime], - TagStandard::Aes256Gcm { key, iv } => vec![tag_kind, key, iv], TagStandard::Server(url) => vec![tag_kind, url.to_string()], - TagStandard::Sha256(hash) => vec![tag_kind, hash.to_string()], - TagStandard::Size(bytes) => vec![tag_kind, bytes.to_string()], - TagStandard::Dim(dim) => vec![tag_kind, dim.to_string()], - TagStandard::Magnet(uri) => vec![tag_kind, uri], - TagStandard::Blurhash(data) => vec![tag_kind, data], TagStandard::Streaming(url) => vec![tag_kind, url.to_string()], TagStandard::Recording(url) => vec![tag_kind, url.to_string()], TagStandard::Starts(timestamp) => { diff --git a/crates/nostr/src/nips/nip94.rs b/crates/nostr/src/nips/nip94.rs index 836e512c5..f25942a79 100644 --- a/crates/nostr/src/nips/nip94.rs +++ b/crates/nostr/src/nips/nip94.rs @@ -2,19 +2,101 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP94: File Metadata +//! NIP-94: File Metadata //! //! <https://github.com/nostr-protocol/nips/blob/master/94.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; +use core::str::FromStr; +use hashes::hex::HexToArrayError; use hashes::sha256::Hash as Sha256Hash; -use crate::{ImageDimensions, Tag, TagKind, TagStandard, Url}; +use super::util::{take_and_parse_from_str, take_string}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::{image, url}; +use crate::{ImageDimensions, Url}; + +const URL: &str = "url"; +const MIME_TYPE: &str = "m"; +const SHA256: &str = "x"; +const ORIGINAL_HASH: &str = "ox"; +const SIZE: &str = "size"; +const DIMENSIONS: &str = "dim"; +const MAGNET: &str = "magnet"; +const TORRENT_INFOHASH: &str = "i"; +const BLURHASH: &str = "blurhash"; +const THUMB: &str = "thumb"; +const IMAGE: &str = "image"; +const SUMMARY: &str = "summary"; +const ALT: &str = "alt"; +const FALLBACK: &str = "fallback"; +const SERVICE: &str = "service"; + +/// NIP-94 error +#[derive(Debug, PartialEq, Eq)] +pub enum Error { + /// Parse int error + ParseInt(ParseIntError), + /// Hex decoding error + Hex(HexToArrayError), + /// URL parse error + Url(url::ParseError), + /// Image error + Image(image::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => e.fmt(f), + Self::Hex(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Image(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<HexToArrayError> for Error { + fn from(e: HexToArrayError) -> Self { + Self::Hex(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<image::Error> for Error { + fn from(e: image::Error) -> Self { + Self::Image(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} -/// Potential errors returned when parsing tags into a [FileMetadata] struct +/// Potential errors returned when parsing tags into a [`FileMetadata`] struct. #[derive(Debug, PartialEq, Eq)] pub enum FileMetadataError { /// The URL of the file is missing (no `url` tag) @@ -37,6 +119,155 @@ impl fmt::Display for FileMetadataError { } } +/// Standardized NIP-94 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/94.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip94Tag { + /// `url` tag + Url(Url), + /// `m` tag + MimeType(String), + /// `x` tag + Sha256(Sha256Hash), + /// `ox` tag + OriginalHash(Sha256Hash), + /// `size` tag + Size(usize), + /// `dim` tag + Dim(ImageDimensions), + /// `magnet` tag + Magnet(String), + /// `i` tag + TorrentInfohash(String), + /// `blurhash` tag + Blurhash(String), + /// `thumb` tag + Thumb { + /// Thumbnail URL + url: Url, + /// Optional SHA256 hash + hash: Option<Sha256Hash>, + }, + /// `image` tag + Image { + /// Preview image URL + url: Url, + /// Optional SHA256 hash + hash: Option<Sha256Hash>, + }, + /// `summary` tag + Summary(String), + /// `alt` tag + Alt(String), + /// `fallback` tag + Fallback(Url), + /// `service` tag + Service(String), +} + +impl TagCodec for Nip94Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + URL => { + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "URL")?; + Ok(Self::Url(url)) + } + MIME_TYPE => Ok(Self::MimeType(take_string(&mut iter, "mime type")?)), + SHA256 => { + let hash: Sha256Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "sha256")?; + Ok(Self::Sha256(hash)) + } + ORIGINAL_HASH => { + let hash: Sha256Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "original hash")?; + Ok(Self::OriginalHash(hash)) + } + SIZE => { + let size: usize = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "size")?; + Ok(Self::Size(size)) + } + DIMENSIONS => { + let dim: ImageDimensions = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "dimensions")?; + Ok(Self::Dim(dim)) + } + MAGNET => Ok(Self::Magnet(take_string(&mut iter, "magnet link")?)), + TORRENT_INFOHASH => Ok(Self::TorrentInfohash(take_string(&mut iter, "infohash")?)), + BLURHASH => Ok(Self::Blurhash(take_string(&mut iter, "blurhash")?)), + THUMB => { + let (url, hash) = parse_thumb_or_image_tag(iter, "thumb URL")?; + Ok(Self::Thumb { url, hash }) + } + IMAGE => { + let (url, hash) = parse_thumb_or_image_tag(iter, "image URL")?; + Ok(Self::Image { url, hash }) + } + SUMMARY => Ok(Self::Summary(take_string(&mut iter, "summary")?)), + ALT => Ok(Self::Alt(take_string(&mut iter, "alt")?)), + FALLBACK => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "fallback URL")?; + Ok(Self::Fallback(url)) + } + SERVICE => Ok(Self::Service(take_string(&mut iter, "service name")?)), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Url(url) => Tag::new(vec![String::from(URL), url.to_string()]), + Self::MimeType(mime_type) => { + Tag::new(vec![String::from(MIME_TYPE), mime_type.to_string()]) + } + Self::Sha256(hash) => Tag::new(vec![String::from(SHA256), hash.to_string()]), + Self::OriginalHash(hash) => { + Tag::new(vec![String::from(ORIGINAL_HASH), hash.to_string()]) + } + Self::Size(size) => Tag::new(vec![String::from(SIZE), size.to_string()]), + Self::Dim(dim) => Tag::new(vec![String::from(DIMENSIONS), dim.to_string()]), + Self::Magnet(uri) => Tag::new(vec![String::from(MAGNET), uri.to_string()]), + Self::TorrentInfohash(infohash) => { + Tag::new(vec![String::from(TORRENT_INFOHASH), infohash.to_string()]) + } + Self::Blurhash(blurhash) => { + Tag::new(vec![String::from(BLURHASH), blurhash.to_string()]) + } + Self::Thumb { url, hash } => { + let mut tag = vec![String::from(THUMB), url.to_string()]; + if let Some(hash) = hash { + tag.push(hash.to_string()); + } + Tag::new(tag) + } + Self::Image { url, hash } => { + let mut tag = vec![String::from(IMAGE), url.to_string()]; + if let Some(hash) = hash { + tag.push(hash.to_string()); + } + Tag::new(tag) + } + Self::Summary(summary) => Tag::new(vec![String::from(SUMMARY), summary.to_string()]), + Self::Alt(alt) => Tag::new(vec![String::from(ALT), alt.to_string()]), + Self::Fallback(url) => Tag::new(vec![String::from(FALLBACK), url.to_string()]), + Self::Service(service) => Tag::new(vec![String::from(SERVICE), service.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip94Tag); + /// File Metadata #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct FileMetadata { @@ -46,16 +277,34 @@ pub struct FileMetadata { pub mime_type: String, /// SHA256 of file pub hash: Sha256Hash, - /// AES 256 GCM - pub aes_256_gcm: Option<(String, String)>, + /// SHA-256 of the original file before any server-side transforms (`ox` tag) + pub original_hash: Option<Sha256Hash>, /// Size in bytes pub size: Option<usize>, /// Size in pixels pub dim: Option<ImageDimensions>, /// Magnet pub magnet: Option<String>, + /// Torrent infohash + pub torrent_infohash: Option<String>, /// Blurhash pub blurhash: Option<String>, + /// Thumbnail URL + pub thumb: Option<Url>, + /// Thumbnail SHA256 hash + pub thumb_hash: Option<Sha256Hash>, + /// Preview image URL + pub image: Option<Url>, + /// Preview image SHA256 hash + pub image_hash: Option<Sha256Hash>, + /// Short text summary / description + pub summary: Option<String>, + /// Alt text for accessibility + pub alt: Option<String>, + /// Fallback download URLs + pub fallback: Vec<Url>, + /// Serving service type + pub service: Option<String>, } impl FileMetadata { @@ -68,61 +317,123 @@ impl FileMetadata { url, mime_type: mime_type.into(), hash, - aes_256_gcm: None, + original_hash: None, size: None, dim: None, magnet: None, + torrent_infohash: None, blurhash: None, + thumb: None, + thumb_hash: None, + image: None, + image_hash: None, + summary: None, + alt: None, + fallback: Vec::new(), + service: None, } } - /// Add AES 256 GCM - pub fn aes_256_gcm<S>(self, key: S, iv: S) -> Self - where - S: Into<String>, - { - Self { - aes_256_gcm: Some((key.into(), iv.into())), - ..self - } + /// Set SHA-256 of the original file before server-side transforms (`ox` tag) + pub fn original_hash(mut self, hash: Sha256Hash) -> Self { + self.original_hash = Some(hash); + self } /// Add file size (bytes) - pub fn size(self, size: usize) -> Self { - Self { - size: Some(size), - ..self - } + pub fn size(mut self, size: usize) -> Self { + self.size = Some(size); + self } /// Add file size (pixels) - pub fn dimensions(self, dim: ImageDimensions) -> Self { - Self { - dim: Some(dim), - ..self - } + pub fn dimensions(mut self, dim: ImageDimensions) -> Self { + self.dim = Some(dim); + self } /// Add magnet - pub fn magnet<S>(self, magnet: S) -> Self + pub fn magnet<S>(mut self, magnet: S) -> Self where S: Into<String>, { - Self { - magnet: Some(magnet.into()), - ..self - } + self.magnet = Some(magnet.into()); + self + } + + /// Add torrent infohash + pub fn torrent_infohash<S>(mut self, infohash: S) -> Self + where + S: Into<String>, + { + self.torrent_infohash = Some(infohash.into()); + self } /// Add blurhash - pub fn blurhash<S>(self, blurhash: S) -> Self + pub fn blurhash<S>(mut self, blurhash: S) -> Self where S: Into<String>, { - Self { - blurhash: Some(blurhash.into()), - ..self - } + self.blurhash = Some(blurhash.into()); + self + } + + /// Add thumbnail URL + pub fn thumb(mut self, thumb: Url) -> Self { + self.thumb = Some(thumb); + self + } + + /// Add thumbnail SHA256 hash + pub fn thumb_hash(mut self, hash: Sha256Hash) -> Self { + self.thumb_hash = Some(hash); + self + } + + /// Add preview image URL + pub fn image(mut self, image: Url) -> Self { + self.image = Some(image); + self + } + + /// Add preview image SHA256 hash + pub fn image_hash(mut self, hash: Sha256Hash) -> Self { + self.image_hash = Some(hash); + self + } + + /// Add short text summary / description + pub fn summary<S>(mut self, summary: S) -> Self + where + S: Into<String>, + { + self.summary = Some(summary.into()); + self + } + + /// Add alt text for accessibility + pub fn alt<S>(mut self, alt: S) -> Self + where + S: Into<String>, + { + self.alt = Some(alt.into()); + self + } + + /// Add a fallback download URL + pub fn add_fallback(mut self, url: Url) -> Self { + self.fallback.push(url); + self + } + + /// Add the serving service type + pub fn service<S>(mut self, service: S) -> Self + where + S: Into<String>, + { + self.service = Some(service.into()); + self } } @@ -132,37 +443,86 @@ impl From<FileMetadata> for Vec<Tag> { url, mime_type, hash, - aes_256_gcm, + original_hash, size, dim, magnet, + torrent_infohash, blurhash, + thumb, + thumb_hash, + image, + image_hash, + summary, + alt, + fallback, + service, } = metadata; let mut tags: Vec<Tag> = Vec::with_capacity(3); - tags.push(Tag::from_standardized(TagStandard::Url(url))); - tags.push(Tag::from_standardized(TagStandard::MimeType(mime_type))); - tags.push(Tag::from_standardized(TagStandard::Sha256(hash))); + tags.push(Nip94Tag::Url(url).to_tag()); + tags.push(Nip94Tag::MimeType(mime_type).to_tag()); + tags.push(Nip94Tag::Sha256(hash).to_tag()); - if let Some((key, iv)) = aes_256_gcm { - tags.push(Tag::from_standardized(TagStandard::Aes256Gcm { key, iv })); + if let Some(hash) = original_hash { + tags.push(Nip94Tag::OriginalHash(hash).to_tag()); } if let Some(size) = size { - tags.push(Tag::from_standardized(TagStandard::Size(size))); + tags.push(Nip94Tag::Size(size).to_tag()); } if let Some(dim) = dim { - tags.push(Tag::from_standardized(TagStandard::Dim(dim))); + tags.push(Nip94Tag::Dim(dim).to_tag()); } if let Some(magnet) = magnet { - tags.push(Tag::from_standardized(TagStandard::Magnet(magnet))); + tags.push(Nip94Tag::Magnet(magnet).to_tag()); + } + + if let Some(infohash) = torrent_infohash { + tags.push(Nip94Tag::TorrentInfohash(infohash).to_tag()); } if let Some(blurhash) = blurhash { - tags.push(Tag::from_standardized(TagStandard::Blurhash(blurhash))); + tags.push(Nip94Tag::Blurhash(blurhash).to_tag()); + } + + if let Some(url) = thumb { + tags.push( + Nip94Tag::Thumb { + url, + hash: thumb_hash, + } + .to_tag(), + ); + } + + if let Some(url) = image { + tags.push( + Nip94Tag::Image { + url, + hash: image_hash, + } + .to_tag(), + ); + } + + if let Some(summary) = summary { + tags.push(Nip94Tag::Summary(summary).to_tag()); + } + + if let Some(alt) = alt { + tags.push(Nip94Tag::Alt(alt).to_tag()); + } + + for url in fallback { + tags.push(Nip94Tag::Fallback(url).to_tag()); + } + + if let Some(service) = service { + tags.push(Nip94Tag::Service(service).to_tag()); } tags @@ -173,126 +533,265 @@ impl TryFrom<Vec<Tag>> for FileMetadata { type Error = FileMetadataError; fn try_from(value: Vec<Tag>) -> Result<Self, Self::Error> { - let url = match value - .iter() - .find(|t| t.kind() == TagKind::Url) - .map(|t| t.standardized()) - { - Some(Some(TagStandard::Url(url))) => Ok(url), - _ => Err(Self::Error::MissingUrl), - }?; - - let mime = match value - .iter() - .find(|t| { - let t = t.standardized(); - matches!(t, Some(TagStandard::MimeType(..))) - }) - .map(|t| t.standardized()) - { - Some(Some(TagStandard::MimeType(mime))) => Ok(mime), - _ => Err(Self::Error::MissingMimeType), - }?; - - let sha256 = match value - .iter() - .find(|t| { - let t = t.standardized(); - matches!(t, Some(TagStandard::Sha256(..))) - }) - .map(|t| t.standardized()) - { - Some(Some(TagStandard::Sha256(sha256))) => Ok(sha256), - _ => Err(Self::Error::MissingSha), - }?; - - let mut metadata = FileMetadata::new(url.clone(), mime, sha256); - - if let Some(TagStandard::Aes256Gcm { key, iv }) = value.iter().find_map(|t| { - let t = t.standardized(); - if matches!(t, Some(TagStandard::Aes256Gcm { .. })) { - t - } else { - None + let mut url: Option<Url> = None; + let mut mime_type: Option<String> = None; + let mut hash: Option<Sha256Hash> = None; + let mut original_hash: Option<Sha256Hash> = None; + let mut size: Option<usize> = None; + let mut dim: Option<ImageDimensions> = None; + let mut magnet: Option<String> = None; + let mut torrent_infohash: Option<String> = None; + let mut blurhash: Option<String> = None; + let mut thumb: Option<Url> = None; + let mut thumb_hash: Option<Sha256Hash> = None; + let mut image: Option<Url> = None; + let mut image_hash: Option<Sha256Hash> = None; + let mut summary: Option<String> = None; + let mut alt: Option<String> = None; + let mut fallback: Vec<Url> = Vec::new(); + let mut service: Option<String> = None; + + for tag in value.iter().filter_map(|tag| Nip94Tag::try_from(tag).ok()) { + match tag { + Nip94Tag::Url(value) => { + if url.is_none() { + url = Some(value); + } + } + Nip94Tag::MimeType(value) => { + if mime_type.is_none() { + mime_type = Some(value); + } + } + Nip94Tag::Sha256(value) => { + if hash.is_none() { + hash = Some(value); + } + } + Nip94Tag::OriginalHash(value) => { + if original_hash.is_none() { + original_hash = Some(value); + } + } + Nip94Tag::Size(value) => { + if size.is_none() { + size = Some(value); + } + } + Nip94Tag::Dim(value) => { + if dim.is_none() { + dim = Some(value); + } + } + Nip94Tag::Magnet(value) => { + if magnet.is_none() { + magnet = Some(value); + } + } + Nip94Tag::TorrentInfohash(value) => { + if torrent_infohash.is_none() { + torrent_infohash = Some(value); + } + } + Nip94Tag::Blurhash(value) => { + if blurhash.is_none() { + blurhash = Some(value); + } + } + Nip94Tag::Thumb { url, hash } => { + if thumb.is_none() { + thumb = Some(url); + } + if thumb_hash.is_none() { + thumb_hash = hash; + } + } + Nip94Tag::Image { url, hash } => { + if image.is_none() { + image = Some(url); + } + if image_hash.is_none() { + image_hash = hash; + } + } + Nip94Tag::Summary(value) => { + if summary.is_none() { + summary = Some(value); + } + } + Nip94Tag::Alt(value) => { + if alt.is_none() { + alt = Some(value); + } + } + Nip94Tag::Fallback(value) => fallback.push(value), + Nip94Tag::Service(value) => { + if service.is_none() { + service = Some(value); + } + } } - }) { - metadata = metadata.aes_256_gcm(key, iv); } - if let Some(TagStandard::Size(size)) = value.iter().find_map(|t| { - let t = t.standardized(); - if matches!(t, Some(TagStandard::Size { .. })) { - t - } else { - None - } - }) { + let url = url.ok_or(FileMetadataError::MissingUrl)?; + let mime_type = mime_type.ok_or(FileMetadataError::MissingMimeType)?; + let hash = hash.ok_or(FileMetadataError::MissingSha)?; + + let mut metadata = FileMetadata::new(url, mime_type, hash); + + if let Some(hash) = original_hash { + metadata = metadata.original_hash(hash); + } + + if let Some(size) = size { metadata = metadata.size(size); } - if let Some(TagStandard::Dim(dim)) = value.iter().find_map(|t| { - let t = t.standardized(); - if matches!(t, Some(TagStandard::Dim { .. })) { - t - } else { - None - } - }) { + if let Some(dim) = dim { metadata = metadata.dimensions(dim); } - if let Some(TagStandard::Magnet(magnet)) = value.iter().find_map(|t| { - let t = t.standardized(); - if matches!(t, Some(TagStandard::Magnet { .. })) { - t - } else { - None - } - }) { + if let Some(magnet) = magnet { metadata = metadata.magnet(magnet); } - if let Some(TagStandard::Blurhash(bh)) = value.iter().find_map(|t| { - let t = t.standardized(); - if matches!(t, Some(TagStandard::Blurhash { .. })) { - t - } else { - None - } - }) { - metadata = metadata.blurhash(bh); + if let Some(infohash) = torrent_infohash { + metadata = metadata.torrent_infohash(infohash); + } + + if let Some(blurhash) = blurhash { + metadata = metadata.blurhash(blurhash); + } + + if let Some(url) = thumb { + metadata = metadata.thumb(url); + } + + if let Some(hash) = thumb_hash { + metadata = metadata.thumb_hash(hash); + } + + if let Some(url) = image { + metadata = metadata.image(url); + } + + if let Some(hash) = image_hash { + metadata = metadata.image_hash(hash); + } + + if let Some(summary) = summary { + metadata = metadata.summary(summary); + } + + if let Some(alt) = alt { + metadata = metadata.alt(alt); + } + + for url in fallback { + metadata = metadata.add_fallback(url); + } + + if let Some(service) = service { + metadata = metadata.service(service); } Ok(metadata) } } +fn parse_thumb_or_image_tag<T, S>( + mut iter: T, + missing_error: &'static str, +) -> Result<(Url, Option<Sha256Hash>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, missing_error)?; + let hash: Option<Sha256Hash> = match iter.next() { + Some(hash) if !hash.as_ref().is_empty() => Some(Sha256Hash::from_str(hash.as_ref())?), + _ => None, + }; + + Ok((url, hash)) +} + #[cfg(test)] mod tests { use core::str::FromStr; use super::*; - use crate::{ImageDimensions, Tag}; const IMAGE_URL: &str = "https://image.nostr.build/99a95fcb4b7a2591ad32467032c52a62d90a204d3b176bc2459ad7427a3f2b89.jpg"; const IMAGE_HASH: &str = "1aea8e98e0e5d969b7124f553b88dfae47d1f00472ea8c0dbf4ac4577d39ef02"; + const THUMB_URL: &str = "https://image.nostr.build/thumb.jpg"; + const THUMB_HASH: &str = "2aea8e98e0e5d969b7124f553b88dfae47d1f00472ea8c0dbf4ac4577d39ef02"; + + #[test] + fn test_standardized_thumb_tag() { + let url = Url::parse(THUMB_URL).unwrap(); + let hash = Sha256Hash::from_str(THUMB_HASH).unwrap(); + let tag = vec![String::from("thumb"), url.to_string(), hash.to_string()]; + let parsed = Nip94Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip94Tag::Thumb { + url: url.clone(), + hash: Some(hash), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_standardized_service_tag() { + let tag = vec![String::from("service"), String::from("nip96")]; + let parsed = Nip94Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip94Tag::Service(String::from("nip96"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } #[test] fn parses_valid_tag_vector() { let url = Url::parse(IMAGE_URL).unwrap(); let hash = Sha256Hash::from_str(IMAGE_HASH).unwrap(); + let thumb_url = Url::parse(THUMB_URL).unwrap(); + let thumb_hash = Sha256Hash::from_str(THUMB_HASH).unwrap(); let dim = ImageDimensions { width: 640, height: 640, }; let tags = vec![ - Tag::from_standardized(TagStandard::Dim(dim)), - Tag::from_standardized(TagStandard::Sha256(hash)), - Tag::from_standardized(TagStandard::Url(url.clone())), - Tag::from_standardized(TagStandard::MimeType(String::from("image/jpeg"))), + Nip94Tag::Dim(dim).to_tag(), + Nip94Tag::Sha256(hash).to_tag(), + Nip94Tag::Url(url.clone()).to_tag(), + Nip94Tag::MimeType(String::from("image/jpeg")).to_tag(), + Nip94Tag::OriginalHash(hash).to_tag(), + Nip94Tag::TorrentInfohash(String::from("abc123")).to_tag(), + Nip94Tag::Thumb { + url: thumb_url.clone(), + hash: Some(thumb_hash), + } + .to_tag(), + Nip94Tag::Summary(String::from("example summary")).to_tag(), + Nip94Tag::Alt(String::from("example alt")).to_tag(), + Nip94Tag::Fallback(Url::parse("https://fallback.example.com/file.jpg").unwrap()) + .to_tag(), + Nip94Tag::Service(String::from("nip96")).to_tag(), ]; let got = FileMetadata::try_from(tags).unwrap(); - let expected = FileMetadata::new(url, "image/jpeg", hash).dimensions(dim); + let expected = FileMetadata::new(url, "image/jpeg", hash) + .original_hash(hash) + .dimensions(dim) + .torrent_infohash("abc123") + .thumb(thumb_url) + .thumb_hash(thumb_hash) + .summary("example summary") + .alt("example alt") + .add_fallback(Url::parse("https://fallback.example.com/file.jpg").unwrap()) + .service("nip96"); assert_eq!(expected, got); } @@ -305,9 +804,9 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized(TagStandard::Dim(dim)), - Tag::from_standardized(TagStandard::Sha256(hash)), - Tag::from_standardized(TagStandard::MimeType(String::from("image/jpeg"))), + Nip94Tag::Dim(dim).to_tag(), + Nip94Tag::Sha256(hash).to_tag(), + Nip94Tag::MimeType(String::from("image/jpeg")).to_tag(), ]; let got = FileMetadata::try_from(tags).unwrap_err(); @@ -323,9 +822,9 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized(TagStandard::Dim(dim)), - Tag::from_standardized(TagStandard::Sha256(hash)), - Tag::from_standardized(TagStandard::Url(url.clone())), + Nip94Tag::Dim(dim).to_tag(), + Nip94Tag::Sha256(hash).to_tag(), + Nip94Tag::Url(url).to_tag(), ]; let got = FileMetadata::try_from(tags).unwrap_err(); @@ -340,9 +839,9 @@ mod tests { height: 640, }; let tags = vec![ - Tag::from_standardized(TagStandard::Dim(dim)), - Tag::from_standardized(TagStandard::Url(url.clone())), - Tag::from_standardized(TagStandard::MimeType(String::from("image/jpeg"))), + Nip94Tag::Dim(dim).to_tag(), + Nip94Tag::Url(url).to_tag(), + Nip94Tag::MimeType(String::from("image/jpeg")).to_tag(), ]; let got = FileMetadata::try_from(tags).unwrap_err(); diff --git a/crates/nostr/src/nips/util.rs b/crates/nostr/src/nips/util.rs index 4dfc36d0c..b502c9c27 100644 --- a/crates/nostr/src/nips/util.rs +++ b/crates/nostr/src/nips/util.rs @@ -31,6 +31,16 @@ where take_and_parse_optional(iter, RelayUrl::parse) } +#[inline] +pub(super) fn take_and_parse_optional_from_str<I, S, T>(iter: &mut I) -> Result<Option<T>, T::Err> +where + I: Iterator<Item = S>, + S: AsRef<str>, + T: FromStr, +{ + take_and_parse_optional(iter, T::from_str) +} + /// Take and parse an **optional** value with the provided parser. /// /// If the value is missing or empty, `None` is returned. @@ -122,24 +132,37 @@ where Ok(event_id) } +#[inline] pub(super) fn take_relay_url<T, S, E>(iter: &mut T) -> Result<RelayUrl, E> where T: Iterator<Item = S>, S: AsRef<str>, E: From<url::Error> + From<TagCodecError>, { - let relay_url: S = iter.next().ok_or(TagCodecError::Missing("relay URL"))?; - let relay_url: RelayUrl = RelayUrl::parse(relay_url.as_ref())?; - Ok(relay_url) + take_and_parse_from_str(iter, "relay URL") } +#[inline] pub(super) fn take_timestamp<T, S, E>(iter: &mut T) -> Result<Timestamp, E> where T: Iterator<Item = S>, S: AsRef<str>, E: From<ParseIntError> + From<TagCodecError>, { - let timestamp: S = iter.next().ok_or(TagCodecError::Missing("timestamp"))?; - let timestamp: Timestamp = Timestamp::from_str(timestamp.as_ref())?; - Ok(timestamp) + take_and_parse_from_str(iter, "timestamp") +} + +pub(super) fn take_and_parse_from_str<O, T, S, E>( + iter: &mut T, + missing_error: &'static str, +) -> Result<O, E> +where + T: Iterator<Item = S>, + S: AsRef<str>, + O: FromStr, + E: From<O::Err> + From<TagCodecError>, +{ + let value: S = iter.next().ok_or(TagCodecError::Missing(missing_error))?; + let value: O = O::from_str(value.as_ref())?; + Ok(value) } diff --git a/rfs/nostr-blossom/src/bud01.rs b/rfs/nostr-blossom/src/bud01.rs index cd0a46879..bd8e5f47b 100644 --- a/rfs/nostr-blossom/src/bud01.rs +++ b/rfs/nostr-blossom/src/bud01.rs @@ -103,7 +103,9 @@ impl From<BlossomAuthorizationScope> for Vec<Tag> { match value { BlossomAuthorizationScope::BlobSha256Hashes(hashes) => { for hash in hashes.into_iter() { - tags.push(Tag::from_standardized(TagStandard::Sha256(hash))); + let tag = + Tag::parse(["x".to_string(), hash.to_string()]).expect("BUG: invalid tag"); + tags.push(tag); } } BlossomAuthorizationScope::ServerUrl(url) => { From 424ecf2f24d1186adcd06341e1ade0674e9c2895 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Thu, 30 Apr 2026 11:49:22 +0200 Subject: [PATCH 16/40] nostr: rework NIP-88 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/kind.rs | 23 --- crates/nostr/src/event/tag/standard.rs | 94 ---------- crates/nostr/src/nips/nip88.rs | 232 +++++++++++++++++++------ 3 files changed, 183 insertions(+), 166 deletions(-) diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index 80d355b08..ba17de27d 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -99,14 +99,8 @@ pub enum TagKind<'a> { /// /// <https://github.com/nostr-protocol/nips/blob/master/13.md> Nonce, - /// Option - Option, /// Payload Payload, - /// Poll type - /// - /// <https://github.com/nostr-protocol/nips/blob/master/88.md> - PollType, /// Preimage Preimage, /// Proxy @@ -125,8 +119,6 @@ pub enum TagKind<'a> { Repository, /// Request Request, - /// Response - Response, /// Runtime or environment specification /// /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> @@ -335,9 +327,7 @@ impl<'a> TagKind<'a> { Self::MlsExtensions => "mls_extensions", Self::Name => "name", Self::Nonce => "nonce", - Self::Option => "option", Self::Payload => "payload", - Self::PollType => "polltype", Self::Preimage => "preimage", Self::Proxy => "proxy", Self::PublishedAt => "published_at", @@ -346,7 +336,6 @@ impl<'a> TagKind<'a> { Self::Relays => "relays", Self::Repository => "repo", Self::Request => "request", - Self::Response => "response", Self::Runtime => "runtime", Self::Server => "server", Self::Starts => "starts", @@ -406,9 +395,7 @@ impl<'a> From<&'a str> for TagKind<'a> { "mls_extensions" => Self::MlsExtensions, "name" => Self::Name, "nonce" => Self::Nonce, - "option" => Self::Option, "payload" => Self::Payload, - "polltype" => Self::PollType, "preimage" => Self::Preimage, "proxy" => Self::Proxy, "published_at" => Self::PublishedAt, @@ -417,7 +404,6 @@ impl<'a> From<&'a str> for TagKind<'a> { "relays" => Self::Relays, "repo" => Self::Repository, "request" => Self::Request, - "response" => Self::Response, "runtime" => Self::Runtime, "HEAD" => Self::Head, "server" => Self::Server, @@ -497,18 +483,9 @@ mod tests { assert_eq!(TagKind::from("maintainers"), TagKind::Maintainers); assert_eq!(TagKind::Maintainers.as_str(), "maintainers"); - assert_eq!(TagKind::from("option"), TagKind::Option); - assert_eq!(TagKind::Option.as_str(), "option"); - - assert_eq!(TagKind::from("polltype"), TagKind::PollType); - assert_eq!(TagKind::PollType.as_str(), "polltype"); - assert_eq!(TagKind::from("repo"), TagKind::Repository); assert_eq!(TagKind::Repository.as_str(), "repo"); - assert_eq!(TagKind::from("response"), TagKind::Response); - assert_eq!(TagKind::Response.as_str(), "response"); - assert_eq!(TagKind::from("runtime"), TagKind::Runtime); assert_eq!(TagKind::Runtime.as_str(), "runtime"); diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 627a4d535..247b8bdc2 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -4,7 +4,6 @@ //! Standardized tags -use alloc::borrow::Cow; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::str::FromStr; @@ -20,7 +19,6 @@ use crate::nips::nip48::Protocol; use crate::nips::nip53::{LiveEventMarker, LiveEventStatus}; use crate::nips::nip56::Report; use crate::nips::nip73::{ExternalContentId, Nip73Kind}; -use crate::nips::nip88::{self, PollOption, PollType}; use crate::nips::nip90::DataVendingMachineStatus; #[cfg(feature = "nip98")] use crate::nips::nip98::HttpMethod; @@ -97,22 +95,6 @@ pub enum TagStandard { }, Relay(RelayUrl), Relays(Vec<RelayUrl>), - /// Poll end timestamp - /// - /// <https://github.com/nostr-protocol/nips/blob/master/88.md> - PollEndsAt(Timestamp), - /// Poll Option - /// - /// <https://github.com/nostr-protocol/nips/blob/master/88.md> - PollOption(PollOption), - /// Poll response - /// - /// <https://github.com/nostr-protocol/nips/blob/master/88.md> - PollResponse(String), - /// Poll Type - /// - /// <https://github.com/nostr-protocol/nips/blob/master/88.md> - PollType(PollType), /// Proof of Work /// /// <https://github.com/nostr-protocol/nips/blob/master/13.md> @@ -343,9 +325,6 @@ impl TagStandard { TagKind::Relay => Ok(Self::Relay(RelayUrl::parse(tag_1)?)), TagKind::Extension => Ok(Self::Extension(tag_1.to_string())), TagKind::License => Ok(Self::License(tag_1.to_string())), - // TODO: depending on the event kind, handle the tag in the right way. - TagKind::Response => Ok(Self::PollResponse(tag_1.to_string())), - TagKind::PollType => Ok(Self::PollType(PollType::from_str(tag_1)?)), TagKind::Runtime => Ok(Self::Runtime(tag_1.to_string())), TagKind::Repository => Ok(Self::Repository(tag_1.to_string())), TagKind::Subject => Ok(Self::Subject(tag_1.to_string())), @@ -370,10 +349,6 @@ impl TagStandard { TagKind::Recording => Ok(Self::Recording(Url::parse(tag_1)?)), TagKind::Starts => Ok(Self::Starts(Timestamp::from_str(tag_1)?)), TagKind::Ends => Ok(Self::Ends(Timestamp::from_str(tag_1)?)), - // TODO: at the moment is not possible to use the nip88::ENDS_AT_TAG_KIND const here. Use it when will be possible. - TagKind::Custom(Cow::Borrowed(nip88::ENDS_AT_TAG_KIND_STR)) => { - Ok(Self::PollEndsAt(Timestamp::from_str(tag_1)?)) - } TagKind::Status => match DataVendingMachineStatus::from_str(tag_1) { Ok(status) => Ok(Self::DataVendingMachineStatus { status, @@ -398,10 +373,6 @@ impl TagStandard { let tag_2: &str = tag[2].as_ref(); return match tag_kind { - TagKind::Option => Ok(Self::PollOption(PollOption { - id: tag_1.to_string(), - text: tag_2.to_string(), - })), TagKind::Nonce => Ok(Self::POW { nonce: tag_1.parse()?, difficulty: tag_2.parse()?, @@ -508,10 +479,6 @@ impl TagStandard { }) } Self::Relay(..) => TagKind::Relay, - Self::PollEndsAt(..) => nip88::ENDS_AT_TAG_KIND, - Self::PollOption { .. } => TagKind::Option, - Self::PollResponse(..) => TagKind::Response, - Self::PollType(..) => TagKind::PollType, Self::POW { .. } => TagKind::Nonce, Self::Client { .. } => TagKind::Client, Self::ContentWarning { .. } => TagKind::ContentWarning, @@ -659,10 +626,6 @@ impl From<TagStandard> for Vec<String> { TagStandard::Kind { kind, .. } => vec![tag_kind, kind.to_string()], TagStandard::Nip73Kind { kind, .. } => vec![tag_kind, kind.to_string()], TagStandard::Relay(url) => vec![tag_kind, url.to_string()], - TagStandard::PollEndsAt(ends_at) => vec![tag_kind, ends_at.to_string()], - TagStandard::PollOption(opt) => vec![tag_kind, opt.id, opt.text], - TagStandard::PollResponse(response) => vec![tag_kind, response], - TagStandard::PollType(t) => vec![tag_kind, t.to_string()], TagStandard::POW { nonce, difficulty } => { vec![tag_kind, nonce.to_string(), difficulty.to_string()] } @@ -1344,35 +1307,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec!["endsAt", "1600000000"], - TagStandard::PollEndsAt(Timestamp::from(1600000000)).to_vec() - ); - - assert_eq!( - vec!["option", "qj518h583", "Yay"], - TagStandard::PollOption(PollOption { - id: String::from("qj518h583"), - text: String::from("Yay"), - }) - .to_vec() - ); - - assert_eq!( - vec!["response", "qj518h583"], - TagStandard::PollResponse(String::from("qj518h583")).to_vec() - ); - - assert_eq!( - vec!["polltype", "singlechoice"], - TagStandard::PollType(PollType::SingleChoice).to_vec() - ); - - assert_eq!( - vec!["polltype", "multiplechoice"], - TagStandard::PollType(PollType::MultipleChoice).to_vec() - ); - assert_eq!( vec!["client", "voyage"], TagStandard::Client { @@ -1776,34 +1710,6 @@ mod tests { } ); - assert_eq!( - TagStandard::parse(&["endsAt", "1600000000"]).unwrap(), - TagStandard::PollEndsAt(Timestamp::from(1600000000)) - ); - - assert_eq!( - TagStandard::parse(&["option", "qj518h583", "Yay"]).unwrap(), - TagStandard::PollOption(PollOption { - id: String::from("qj518h583"), - text: String::from("Yay"), - }) - ); - - assert_eq!( - TagStandard::parse(&["response", "qj518h583"]).unwrap(), - TagStandard::PollResponse(String::from("qj518h583")) - ); - - assert_eq!( - TagStandard::parse(&["polltype", "singlechoice"]).unwrap(), - TagStandard::PollType(PollType::SingleChoice) - ); - - assert_eq!( - TagStandard::parse(&["polltype", "multiplechoice"]).unwrap(), - TagStandard::PollType(PollType::MultipleChoice) - ); - assert_eq!( TagStandard::parse(&["client", "voyage"]).unwrap(), TagStandard::Client { diff --git a/crates/nostr/src/nips/nip88.rs b/crates/nostr/src/nips/nip88.rs index 66d09957e..f85fb96c0 100644 --- a/crates/nostr/src/nips/nip88.rs +++ b/crates/nostr/src/nips/nip88.rs @@ -2,28 +2,38 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP88: Polls +//! NIP-88: Polls //! //! <https://github.com/nostr-protocol/nips/blob/master/88.md> -use alloc::borrow::Cow; use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; use core::str::FromStr; -use crate::{Event, EventBuilder, EventId, Kind, RelayUrl, Tag, TagKind, TagStandard, Timestamp}; +use super::util::{take_and_parse_from_str, take_relay_url, take_string, take_timestamp}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url; +use crate::{Event, EventBuilder, EventId, Kind, RelayUrl, Timestamp}; -pub(crate) const ENDS_AT_TAG_KIND_STR: &str = "endsAt"; -pub(crate) const ENDS_AT_TAG_KIND: TagKind = TagKind::Custom(Cow::Borrowed(ENDS_AT_TAG_KIND_STR)); +const ENDS_AT: &str = "endsAt"; +const POLL_TYPE: &str = "polltype"; +const POLL_OPTION: &str = "option"; +const POLL_RESPONSE: &str = "response"; +const RELAY: &str = "relay"; /// NIP88 error #[derive(Debug, PartialEq, Eq)] pub enum Error { + /// Url error + Url(url::Error), + /// Parse Int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), /// Unknown poll type UnknownPollType, - /// Unexpected tag - UnexpectedTag, } impl core::error::Error for Error {} @@ -31,12 +41,32 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Url(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::UnknownPollType => f.write_str("unknown poll type"), - Self::UnexpectedTag => f.write_str("unexpected tag"), } } } +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Poll type #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum PollType { @@ -83,6 +113,80 @@ pub struct PollOption { pub text: String, } +/// Standardized NIP-88 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/88.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip88Tag { + /// Poll option + PollOption(PollOption), + /// Poll response + PollResponse(String), + /// Poll type + PollType(PollType), + /// Relay URL + Relay(RelayUrl), + /// Poll expiration timestamp + PollEndsAt(Timestamp), +} + +impl TagCodec for Nip88Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + POLL_OPTION => Ok(Self::PollOption(PollOption { + id: take_string(&mut iter, "poll ID")?, + text: take_string(&mut iter, "poll option text")?, + })), + POLL_RESPONSE => Ok(Self::PollResponse(take_string(&mut iter, "poll response")?)), + POLL_TYPE => { + let poll_type: PollType = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "poll type")?; + Ok(Self::PollType(poll_type)) + } + RELAY => { + let relay: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + Ok(Self::Relay(relay)) + } + ENDS_AT => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::PollEndsAt(timestamp)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::PollOption(option) => Tag::new(vec![ + String::from(POLL_OPTION), + option.id.clone(), + option.text.clone(), + ]), + Self::PollResponse(response) => { + Tag::new(vec![String::from(POLL_RESPONSE), response.clone()]) + } + Self::PollType(poll_type) => { + Tag::new(vec![String::from(POLL_TYPE), poll_type.to_string()]) + } + Self::Relay(relay) => Tag::new(vec![String::from(RELAY), relay.to_string()]), + Self::PollEndsAt(timestamp) => { + Tag::new(vec![String::from(ENDS_AT), timestamp.to_string()]) + } + } + } +} + +impl_tag_codec_conversions!(Nip88Tag); + /// Poll #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Poll { @@ -101,41 +205,23 @@ pub struct Poll { impl Poll { /// Parse poll data from an [`Event`]. pub fn from_event(event: &Event) -> Result<Self, Error> { - // Search poll type - let poll_type: PollType = match event.tags.find_standardized(TagKind::PollType) { - Some(TagStandard::PollType(poll_type)) => poll_type, - // Found an unexpected tag. - Some(..) => return Err(Error::UnexpectedTag), - // If no valid "polltype" tag is found, the "singlechoice" will be the default. - None => PollType::SingleChoice, - }; - - // Search poll options - let options: Vec<PollOption> = event - .tags - .filter_standardized(TagKind::Option) - .filter_map(|tag| match tag { - TagStandard::PollOption(option) => Some(option.clone()), - _ => None, - }) - .collect(); - - // Search relays - let relays: Vec<RelayUrl> = event - .tags - .filter_standardized(TagKind::Relay) - .filter_map(|tag| match tag { - TagStandard::Relay(url) => Some(url.clone()), - _ => None, - }) - .collect(); - - // Search ends timestamp - let ends_at: Option<Timestamp> = match event.tags.find_standardized(ENDS_AT_TAG_KIND) { - Some(TagStandard::PollEndsAt(timestamp)) => Some(timestamp), - Some(..) => return Err(Error::UnexpectedTag), - None => None, - }; + let mut poll_type: PollType = PollType::SingleChoice; + let mut options: Vec<PollOption> = Vec::new(); + let mut relays: Vec<RelayUrl> = Vec::new(); + let mut ends_at: Option<Timestamp> = None; + + for tag in event.tags.iter() { + match Nip88Tag::try_from(tag) { + Ok(Nip88Tag::PollType(value)) => poll_type = value, + Ok(Nip88Tag::PollOption(option)) => options.push(option), + Ok(Nip88Tag::Relay(url)) => relays.push(url), + Ok(Nip88Tag::PollEndsAt(timestamp)) => ends_at = Some(timestamp), + Ok(Nip88Tag::PollResponse(..)) | Err(Error::Codec(TagCodecError::Unknown)) => (), + Err(Error::UnknownPollType) + | Err(Error::Codec(TagCodecError::Missing("poll type"))) => (), + Err(e) => return Err(e), + } + } Ok(Self { title: event.content.clone(), @@ -150,18 +236,18 @@ impl Poll { pub(crate) fn to_event_builder(self) -> EventBuilder { let mut tags: Vec<Tag> = Vec::with_capacity(1 + self.options.len() + self.relays.len()); - tags.push(Tag::from_standardized(TagStandard::PollType(self.r#type))); + tags.push(Nip88Tag::PollType(self.r#type).to_tag()); for option in self.options.into_iter() { - tags.push(Tag::from_standardized(TagStandard::PollOption(option))); + tags.push(Nip88Tag::PollOption(option).to_tag()); } for url in self.relays.into_iter() { - tags.push(Tag::relay(url)); + tags.push(Nip88Tag::Relay(url).to_tag()); } if let Some(timestamp) = self.ends_at { - tags.push(Tag::custom(ENDS_AT_TAG_KIND, [timestamp.to_string()])); + tags.push(Nip88Tag::PollEndsAt(timestamp).to_tag()); } EventBuilder::new(Kind::Poll, self.title).tags(tags) @@ -194,7 +280,7 @@ impl PollResponse { Self::SingleChoice { poll_id, response } => { vec![ Tag::event(poll_id), - Tag::from_standardized(TagStandard::PollResponse(response)), + Nip88Tag::PollResponse(response).to_tag(), ] } Self::MultipleChoice { poll_id, responses } => { @@ -203,7 +289,7 @@ impl PollResponse { tags.push(Tag::event(poll_id)); for response in responses.into_iter() { - tags.push(Tag::from_standardized(TagStandard::PollResponse(response))); + tags.push(Nip88Tag::PollResponse(response).to_tag()); } tags @@ -237,6 +323,54 @@ mod tests { assert!(PollType::from_str("unknown").is_err()); } + #[test] + fn test_standardized_poll_tags() { + let option = Nip88Tag::parse(["option", "qj518h583", "Yay"]).unwrap(); + assert_eq!( + option, + Nip88Tag::PollOption(PollOption { + id: "qj518h583".to_string(), + text: "Yay".to_string(), + }) + ); + assert_eq!( + option.to_tag(), + Tag::parse(["option", "qj518h583", "Yay"]).unwrap() + ); + + let poll_type = Nip88Tag::parse(["polltype", "multiplechoice"]).unwrap(); + assert_eq!(poll_type, Nip88Tag::PollType(PollType::MultipleChoice)); + assert_eq!( + poll_type.to_tag(), + Tag::parse(["polltype", "multiplechoice"]).unwrap() + ); + + let relay = RelayUrl::parse("wss://relay.damus.io").unwrap(); + let relay_tag = Nip88Tag::parse(["relay", "wss://relay.damus.io"]).unwrap(); + assert_eq!(relay_tag, Nip88Tag::Relay(relay.clone())); + assert_eq!( + relay_tag.to_tag(), + Tag::parse(["relay", relay.as_str()]).unwrap() + ); + + let ends_at = Nip88Tag::parse(["endsAt", "1788888888"]).unwrap(); + assert_eq!( + ends_at, + Nip88Tag::PollEndsAt(Timestamp::from_secs(1788888888)) + ); + assert_eq!( + ends_at.to_tag(), + Tag::parse(["endsAt", "1788888888"]).unwrap() + ); + + let response = Nip88Tag::parse(["response", "qj518h583"]).unwrap(); + assert_eq!(response, Nip88Tag::PollResponse("qj518h583".to_string())); + assert_eq!( + response.to_tag(), + Tag::parse(["response", "qj518h583"]).unwrap() + ); + } + #[test] fn test_poll_from_event() { let event = Event::from_json(r#"{ From 195964e42c124dd986504b5668db923570c1a708 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Thu, 30 Apr 2026 14:43:09 +0200 Subject: [PATCH 17/40] nostr: rework NIP-53 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/kind.rs | 24 - crates/nostr/src/event/tag/standard.rs | 167 +---- crates/nostr/src/nips/nip53.rs | 886 ++++++++++++++++++++++--- 3 files changed, 789 insertions(+), 288 deletions(-) diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index ba17de27d..9db1fb30d 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -51,8 +51,6 @@ pub enum TagKind<'a> { MergeBase, /// Content warning ContentWarning, - /// Current participants - CurrentParticipants, /// Required dependency /// /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> @@ -63,8 +61,6 @@ pub enum TagKind<'a> { Emoji, /// Encrypted Encrypted, - /// Ends - Ends, /// Expiration /// /// <https://github.com/nostr-protocol/nips/blob/master/40.md> @@ -107,8 +103,6 @@ pub enum TagKind<'a> { Proxy, /// PublishedAt PublishedAt, - /// Recording - Recording, /// Relay Relay, /// Relays @@ -125,12 +119,8 @@ pub enum TagKind<'a> { Runtime, /// Server Server, - /// Starts - Starts, /// Status Status, - /// Streaming - Streaming, /// Subject Subject, /// Summary @@ -139,8 +129,6 @@ pub enum TagKind<'a> { Title, /// Thumbnail Thumb, - /// Total participants - TotalParticipants, /// Tracker Tracker, /// Url @@ -306,12 +294,10 @@ impl<'a> TagKind<'a> { Self::Clone => "clone", Self::Commit => "commit", Self::ContentWarning => "content-warning", - Self::CurrentParticipants => "current_participants", Self::Dependency => "dep", Self::Description => "description", Self::Emoji => "emoji", Self::Encrypted => "encrypted", - Self::Ends => "ends", Self::Expiration => "expiration", Self::Extension => "extension", Self::File => "file", @@ -331,21 +317,17 @@ impl<'a> TagKind<'a> { Self::Preimage => "preimage", Self::Proxy => "proxy", Self::PublishedAt => "published_at", - Self::Recording => "recording", Self::Relay => "relay", Self::Relays => "relays", Self::Repository => "repo", Self::Request => "request", Self::Runtime => "runtime", Self::Server => "server", - Self::Starts => "starts", Self::Status => "status", - Self::Streaming => "streaming", Self::Subject => "subject", Self::Summary => "summary", Self::Title => "title", Self::Thumb => "thumb", - Self::TotalParticipants => "total_participants", Self::Tracker => "tracker", Self::Url => "url", Self::Web => "web", @@ -375,12 +357,10 @@ impl<'a> From<&'a str> for TagKind<'a> { "clone" => Self::Clone, "commit" => Self::Commit, "content-warning" => Self::ContentWarning, - "current_participants" => Self::CurrentParticipants, "dep" => Self::Dependency, "description" => Self::Description, "emoji" => Self::Emoji, "encrypted" => Self::Encrypted, - "ends" => Self::Ends, "expiration" => Self::Expiration, "extension" => Self::Extension, "file" => Self::File, @@ -399,7 +379,6 @@ impl<'a> From<&'a str> for TagKind<'a> { "preimage" => Self::Preimage, "proxy" => Self::Proxy, "published_at" => Self::PublishedAt, - "recording" => Self::Recording, "relay" => Self::Relay, "relays" => Self::Relays, "repo" => Self::Repository, @@ -407,14 +386,11 @@ impl<'a> From<&'a str> for TagKind<'a> { "runtime" => Self::Runtime, "HEAD" => Self::Head, "server" => Self::Server, - "starts" => Self::Starts, "status" => Self::Status, - "streaming" => Self::Streaming, "subject" => Self::Subject, "summary" => Self::Summary, "title" => Self::Title, "thumb" => Self::Thumb, - "total_participants" => Self::TotalParticipants, "tracker" => Self::Tracker, "url" => Self::Url, "web" => Self::Web, diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 247b8bdc2..a6bcb3bb6 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -9,14 +9,12 @@ use alloc::vec::Vec; use core::str::FromStr; use hashes::sha256::Hash as Sha256Hash; -use secp256k1::schnorr::Signature; use super::{Error, TagKind}; use crate::event::id::EventId; use crate::nips::nip01::Coordinate; use crate::nips::nip39::Identity; use crate::nips::nip48::Protocol; -use crate::nips::nip53::{LiveEventMarker, LiveEventStatus}; use crate::nips::nip56::Report; use crate::nips::nip73::{ExternalContentId, Nip73Kind}; use crate::nips::nip90::DataVendingMachineStatus; @@ -54,12 +52,6 @@ pub enum TagStandard { /// /// <https://github.com/nostr-protocol/nips/blob/master/56.md> PublicKeyReport(PublicKey, Report), - PublicKeyLiveEvent { - public_key: PublicKey, - relay_url: Option<RelayUrl>, - marker: LiveEventMarker, - proof: Option<Signature>, - }, Reference(String), Hashtag(String), Geohash(String), @@ -132,13 +124,6 @@ pub enum TagStandard { PublishedAt(Timestamp), Url(Url), Server(Url), - Streaming(Url), - Recording(Url), - Starts(Timestamp), - Ends(Timestamp), - LiveEventStatus(LiveEventStatus), - CurrentParticipants(u64), - TotalParticipants(u64), AbsoluteURL(Url), #[cfg(feature = "nip98")] Method(HttpMethod), @@ -345,19 +330,13 @@ impl TagStandard { TagKind::Lnurl => Ok(Self::Lnurl(tag_1.to_string())), TagKind::Name => Ok(Self::Name(tag_1.to_string())), TagKind::Url => Ok(Self::Url(Url::parse(tag_1)?)), - TagKind::Streaming => Ok(Self::Streaming(Url::parse(tag_1)?)), - TagKind::Recording => Ok(Self::Recording(Url::parse(tag_1)?)), - TagKind::Starts => Ok(Self::Starts(Timestamp::from_str(tag_1)?)), - TagKind::Ends => Ok(Self::Ends(Timestamp::from_str(tag_1)?)), TagKind::Status => match DataVendingMachineStatus::from_str(tag_1) { Ok(status) => Ok(Self::DataVendingMachineStatus { status, extra_info: None, }), - Err(_) => Ok(Self::LiveEventStatus(LiveEventStatus::from(tag_1))), /* TODO: check if unknown status error? */ + Err(_) => Err(Error::UnknownStandardizedTag), }, - TagKind::CurrentParticipants => Ok(Self::CurrentParticipants(tag_1.parse()?)), - TagKind::TotalParticipants => Ok(Self::TotalParticipants(tag_1.parse()?)), #[cfg(feature = "nip98")] TagKind::Method => Ok(Self::Method(HttpMethod::from_str(tag_1)?)), TagKind::Payload => Ok(Self::Payload(Sha256Hash::from_str(tag_1)?)), @@ -442,12 +421,10 @@ impl TagStandard { character: Alphabet::P, uppercase: *uppercase, }), - Self::PublicKeyReport(..) | Self::PublicKeyLiveEvent { .. } => { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::P, - uppercase: false, - }) - } + Self::PublicKeyReport(..) => TagKind::SingleLetter(SingleLetterTag { + character: Alphabet::P, + uppercase: false, + }), Self::Reference(..) => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::R, uppercase: false, @@ -503,13 +480,7 @@ impl TagStandard { Self::Lnurl(..) => TagKind::Lnurl, Self::Url(..) => TagKind::Url, Self::Server(..) => TagKind::Server, - Self::Streaming(..) => TagKind::Streaming, - Self::Recording(..) => TagKind::Recording, - Self::Starts(..) => TagKind::Starts, - Self::Ends(..) => TagKind::Ends, - Self::LiveEventStatus(..) | Self::DataVendingMachineStatus { .. } => TagKind::Status, - Self::CurrentParticipants(..) => TagKind::CurrentParticipants, - Self::TotalParticipants(..) => TagKind::TotalParticipants, + Self::DataVendingMachineStatus { .. } => TagKind::Status, Self::AbsoluteURL(..) => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::U, uppercase: false, @@ -580,23 +551,6 @@ impl From<TagStandard> for Vec<String> { TagStandard::PublicKeyReport(pk, report) => { vec![tag_kind, pk.to_string(), report.to_string()] } - TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker, - proof, - } => { - let mut tag = vec![ - tag_kind, - public_key.to_string(), - relay_url.map(|u| u.to_string()).unwrap_or_default(), - marker.to_string(), - ]; - if let Some(proof) = proof { - tag.push(proof.to_string()); - } - tag - } TagStandard::Reference(r) => vec![tag_kind, r], TagStandard::Hashtag(t) => vec![tag_kind, t], TagStandard::Geohash(g) => vec![tag_kind, g], @@ -703,23 +657,6 @@ impl From<TagStandard> for Vec<String> { TagStandard::Lnurl(lnurl) => vec![tag_kind, lnurl], TagStandard::Url(url) => vec![tag_kind, url.to_string()], TagStandard::Server(url) => vec![tag_kind, url.to_string()], - TagStandard::Streaming(url) => vec![tag_kind, url.to_string()], - TagStandard::Recording(url) => vec![tag_kind, url.to_string()], - TagStandard::Starts(timestamp) => { - vec![tag_kind, timestamp.to_string()] - } - TagStandard::Ends(timestamp) => { - vec![tag_kind, timestamp.to_string()] - } - TagStandard::LiveEventStatus(s) => { - vec![tag_kind, s.to_string()] - } - TagStandard::CurrentParticipants(num) => { - vec![tag_kind, num.to_string()] - } - TagStandard::TotalParticipants(num) => { - vec![tag_kind, num.to_string()] - } TagStandard::AbsoluteURL(url) => { vec![tag_kind, url.to_string()] } @@ -899,42 +836,6 @@ where if tag.len() >= 2 { let public_key: PublicKey = PublicKey::from_hex(tag[1].as_ref())?; - if tag.len() >= 5 && !uppercase { - let tag_2: &str = tag[2].as_ref(); - let tag_3: &str = tag[3].as_ref(); - let tag_4: &str = tag[4].as_ref(); - - return Ok(TagStandard::PublicKeyLiveEvent { - public_key, - relay_url: if !tag_2.is_empty() { - Some(RelayUrl::parse(tag_2)?) - } else { - None - }, - marker: LiveEventMarker::from_str(tag_3)?, - proof: Signature::from_str(tag_4).ok(), - }); - } - - if tag.len() >= 4 && !uppercase { - let tag_2: &str = tag[2].as_ref(); - let tag_3: &str = tag[3].as_ref(); - - let relay_url: Option<RelayUrl> = if !tag_2.is_empty() { - Some(RelayUrl::parse(tag_2)?) - } else { - None - }; - - let marker = LiveEventMarker::from_str(tag_3)?; - return Ok(TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker, - proof: None, - }); - } - if tag.len() >= 3 && !uppercase { let tag_2: &str = tag[2].as_ref(); @@ -1406,44 +1307,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io", - "Speaker", - ], - TagStandard::PublicKeyLiveEvent { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: LiveEventMarker::Speaker, - proof: None - } - .to_vec() - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "", - "Participant", - ], - TagStandard::PublicKeyLiveEvent { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: None, - marker: LiveEventMarker::Participant, - proof: None - } - .to_vec() - ); - assert_eq!( vec!["relay", "wss://relay.damus.io"], TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()).to_vec() @@ -1454,24 +1317,6 @@ mod tests { TagStandard::Lnurl(String::from("lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp")).to_vec(), ); - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io", - "Host", - "a5d9290ef9659083c490b303eb7ee41356d8778ff19f2f91776c8dc4443388a64ffcf336e61af4c25c05ac3ae952d1ced889ed655b67790891222aaa15b99fdd" - ], - TagStandard::PublicKeyLiveEvent { - public_key: PublicKey::from_hex( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ).unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - marker: LiveEventMarker::Host, - proof: Some(Signature::from_str("a5d9290ef9659083c490b303eb7ee41356d8778ff19f2f91776c8dc4443388a64ffcf336e61af4c25c05ac3ae952d1ced889ed655b67790891222aaa15b99fdd").unwrap()) - }.to_vec() - ); - assert_eq!( vec!["L", "#t"], TagStandard::LabelNamespace("#t".to_string()).to_vec() diff --git a/crates/nostr/src/nips/nip53.rs b/crates/nostr/src/nips/nip53.rs index ec7716b9d..9985b60cd 100644 --- a/crates/nostr/src/nips/nip53.rs +++ b/crates/nostr/src/nips/nip53.rs @@ -7,35 +7,141 @@ //! <https://github.com/nostr-protocol/nips/blob/master/53.md> use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; use core::str::FromStr; use secp256k1::schnorr::Signature; -use crate::types::{RelayUrl, Url}; -use crate::{ - Alphabet, ImageDimensions, PublicKey, SingleLetterTag, Tag, TagKind, TagStandard, Timestamp, +use super::nip01::{self, Coordinate}; +use super::util::{ + take_and_parse_from_str, take_and_parse_optional_from_str, take_and_parse_optional_relay_url, + take_coordinate, take_event_id, take_optional_string, take_public_key, take_string, + take_timestamp, }; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::key::{self, PublicKey}; +use crate::types::image; +use crate::types::url::{self, RelayUrl, Url}; +use crate::{Event, EventId, ImageDimensions, Kind, TagKind, Timestamp, event}; + +const TITLE: &str = "title"; +const SUMMARY: &str = "summary"; +const IMAGE: &str = "image"; +const STREAMING: &str = "streaming"; +const RECORDING: &str = "recording"; +const STARTS: &str = "starts"; +const ENDS: &str = "ends"; +const STATUS: &str = "status"; +const CURRENT_PARTICIPANTS: &str = "current_participants"; +const TOTAL_PARTICIPANTS: &str = "total_participants"; +const RELAYS: &str = "relays"; +const ROOM: &str = "room"; +const SERVICE: &str = "service"; +const ENDPOINT: &str = "endpoint"; +const PINNED: &str = "pinned"; +const HAND: &str = "hand"; /// NIP53 Error -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq)] pub enum Error { - /// Unknown [`LiveEventMarker`] - UnknownLiveEventMarker(String), + /// Secp256k1 error + Secp256k1(secp256k1::Error), + /// Keys error + Keys(key::Error), + /// Event error + Event(event::Error), + /// Url error + Url(url::Error), + /// URL parse error + UrlParse(url::ParseError), + /// Image error + Image(image::Error), + /// NIP-01 error + NIP01(nip01::Error), + /// Parse int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), /// Description missing from event DescriptionMissing, } +impl core::error::Error for Error {} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::UnknownLiveEventMarker(u) => write!(f, "Unknown marker: {u}"), + Self::Secp256k1(e) => e.fmt(f), + Self::Keys(e) => e.fmt(f), + Self::Event(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::UrlParse(e) => e.fmt(f), + Self::Image(e) => e.fmt(f), + Self::NIP01(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::DescriptionMissing => f.write_str("Event missing a description"), } } } +impl From<secp256k1::Error> for Error { + fn from(e: secp256k1::Error) -> Self { + Self::Secp256k1(e) + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::UrlParse(e) + } +} + +impl From<image::Error> for Error { + fn from(e: image::Error) -> Self { + Self::Image(e) + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::NIP01(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Live Event Marker #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum LiveEventMarker { @@ -45,6 +151,12 @@ pub enum LiveEventMarker { Speaker, /// Participant Participant, + /// Moderator + Moderator, + /// Owner + Owner, + /// Custom role label + Custom(String), } impl fmt::Display for LiveEventMarker { @@ -60,6 +172,9 @@ impl LiveEventMarker { Self::Host => "Host", Self::Speaker => "Speaker", Self::Participant => "Participant", + Self::Moderator => "Moderator", + Self::Owner => "Owner", + Self::Custom(value) => value.as_str(), } } } @@ -68,12 +183,14 @@ impl FromStr for LiveEventMarker { type Err = Error; fn from_str(s: &str) -> Result<Self, Self::Err> { - match s { - "Host" => Ok(Self::Host), - "Speaker" => Ok(Self::Speaker), - "Participant" => Ok(Self::Participant), - s => Err(Error::UnknownLiveEventMarker(s.to_string())), - } + Ok(match s { + "Host" => Self::Host, + "Speaker" => Self::Speaker, + "Participant" => Self::Participant, + "Moderator" => Self::Moderator, + "Owner" => Self::Owner, + other => Self::Custom(other.to_string()), + }) } } @@ -123,6 +240,285 @@ where } } +/// Live event participant tag +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LiveEventParticipant { + /// Participant public key + pub public_key: PublicKey, + /// Participant relay URL + pub relay_url: Option<RelayUrl>, + /// Participant role + pub marker: LiveEventMarker, + /// Optional proof + pub proof: Option<Signature>, +} + +/// Live event space reference +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LiveEventSpace { + /// Coordinate + pub coordinate: Coordinate, + /// Optional relay URL + pub relay_url: Option<RelayUrl>, + /// Optional marker + pub marker: Option<String>, +} + +/// NIP53 tags +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip53Tag { + /// Title + Title(String), + /// Summary + Summary(String), + /// Image + Image(Url, Option<ImageDimensions>), + /// Hashtag + Hashtag(String), + /// Streaming URL + Streaming(Url), + /// Recording URL + Recording(Url), + /// Start timestamp + Starts(Timestamp), + /// End timestamp + Ends(Timestamp), + /// Status + Status(LiveEventStatus), + /// Current participants count + CurrentParticipants(u64), + /// Total participants count + TotalParticipants(u64), + /// Preferred relays + Relays(Vec<RelayUrl>), + /// Participant role tag + Participant(LiveEventParticipant), + /// Room display name + Room(String), + /// Service URL + Service(Url), + /// Endpoint URL + Endpoint(Url), + /// Pinned event + Pinned(EventId), + /// Space reference + Space(LiveEventSpace), + /// Hand raised flag + Hand(bool), +} + +impl TagCodec for Nip53Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + TITLE => Ok(Self::Title(take_string(&mut iter, "title")?)), + SUMMARY => Ok(Self::Summary(take_string(&mut iter, "summary")?)), + IMAGE => { + let image: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "image URL")?; + let dimensions: Option<ImageDimensions> = + take_and_parse_optional_from_str(&mut iter)?; + Ok(Self::Image(image, dimensions)) + } + "t" => { + let hashtag: String = take_string(&mut iter, "hashtag")?; + if hashtag.chars().any(char::is_uppercase) { + return Err( + TagCodecError::Invalid("hashtag contains uppercase characters").into(), + ); + } + + Ok(Self::Hashtag(hashtag)) + } + STREAMING => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "streaming URL")?; + Ok(Self::Streaming(url)) + } + RECORDING => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "recording URL")?; + Ok(Self::Recording(url)) + } + STARTS => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::Starts(timestamp)) + } + ENDS => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::Ends(timestamp)) + } + STATUS => Ok(Self::Status(LiveEventStatus::from(take_string( + &mut iter, "status", + )?))), + CURRENT_PARTICIPANTS => { + let num: u64 = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "current participants")?; + Ok(Self::CurrentParticipants(num)) + } + TOTAL_PARTICIPANTS => { + let num: u64 = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "total participants")?; + Ok(Self::TotalParticipants(num)) + } + RELAYS => { + let mut relays: Vec<RelayUrl> = Vec::new(); + for relay in iter { + relays.push(RelayUrl::parse(relay.as_ref())?); + } + Ok(Self::Relays(relays)) + } + "p" => parse_p_tag(iter), + ROOM => Ok(Self::Room(take_string(&mut iter, "room")?)), + SERVICE => { + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "service URL")?; + Ok(Self::Service(url)) + } + ENDPOINT => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "endpoint URL")?; + Ok(Self::Endpoint(url)) + } + PINNED => { + let event_id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + Ok(Self::Pinned(event_id)) + } + "a" => parse_a_tag(iter), + HAND => parse_hand_tag(iter), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Title(title) => Tag::new(vec![String::from(TITLE), title.clone()]), + Self::Summary(summary) => Tag::new(vec![String::from(SUMMARY), summary.clone()]), + Self::Image(image, dimensions) => { + let mut tag = vec![String::from(IMAGE), image.to_string()]; + if let Some(dimensions) = dimensions { + tag.push(dimensions.to_string()); + } + Tag::new(tag) + } + Self::Hashtag(hashtag) => Tag::new(vec![String::from("t"), hashtag.clone()]), + Self::Streaming(url) => Tag::new(vec![String::from(STREAMING), url.to_string()]), + Self::Recording(url) => Tag::new(vec![String::from(RECORDING), url.to_string()]), + Self::Starts(timestamp) => Tag::new(vec![String::from(STARTS), timestamp.to_string()]), + Self::Ends(timestamp) => Tag::new(vec![String::from(ENDS), timestamp.to_string()]), + Self::Status(status) => Tag::new(vec![String::from(STATUS), status.to_string()]), + Self::CurrentParticipants(count) => { + Tag::new(vec![String::from(CURRENT_PARTICIPANTS), count.to_string()]) + } + Self::TotalParticipants(count) => { + Tag::new(vec![String::from(TOTAL_PARTICIPANTS), count.to_string()]) + } + Self::Relays(relays) => { + let mut tag: Vec<String> = Vec::with_capacity(1 + relays.len()); + tag.push(String::from(RELAYS)); + tag.extend(relays.iter().map(|relay| relay.to_string())); + Tag::new(tag) + } + Self::Participant(participant) => { + let mut tag = vec![ + String::from("p"), + participant.public_key.to_string(), + participant + .relay_url + .as_ref() + .map(|relay| relay.to_string()) + .unwrap_or_default(), + participant.marker.to_string(), + ]; + if let Some(proof) = participant.proof { + tag.push(proof.to_string()); + } + Tag::new(tag) + } + Self::Room(room) => Tag::new(vec![String::from(ROOM), room.clone()]), + Self::Service(service) => Tag::new(vec![String::from(SERVICE), service.to_string()]), + Self::Endpoint(endpoint) => { + Tag::new(vec![String::from(ENDPOINT), endpoint.to_string()]) + } + Self::Pinned(event_id) => Tag::new(vec![String::from(PINNED), event_id.to_hex()]), + Self::Space(space) => { + let mut tag = vec![String::from("a"), space.coordinate.to_string()]; + if let Some(relay_url) = &space.relay_url { + tag.push(relay_url.to_string()); + } + if let Some(marker) = &space.marker { + if space.relay_url.is_none() { + tag.push(String::new()); + } + tag.push(marker.clone()); + } + Tag::new(tag) + } + Self::Hand(hand) => Tag::new(vec![ + String::from(HAND), + if *hand { "1" } else { "0" }.to_string(), + ]), + } + } +} + +impl_tag_codec_conversions!(Nip53Tag); + +fn parse_p_tag<T, S>(mut iter: T) -> Result<Nip53Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_url: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let marker: LiveEventMarker = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "marker")?; + let proof: Option<Signature> = take_and_parse_optional_from_str(&mut iter)?; + + Ok(Nip53Tag::Participant(LiveEventParticipant { + public_key, + relay_url, + marker, + proof, + })) +} + +fn parse_a_tag<T, S>(mut iter: T) -> Result<Nip53Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let coordinate = take_coordinate::<_, _, Error>(&mut iter)?; + let relay_url: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let marker: Option<String> = take_optional_string(&mut iter); + + Ok(Nip53Tag::Space(LiveEventSpace { + coordinate, + relay_url, + marker, + })) +} + +fn parse_hand_tag<T, S>(mut iter: T) -> Result<Nip53Tag, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let hand: S = iter.next().ok_or(TagCodecError::Missing("hand"))?; + let hand: bool = match hand.as_ref() { + "1" => true, + "0" => false, + _ => return Err(TagCodecError::Unknown.into()), + }; + + Ok(Nip53Tag::Hand(hand)) +} + /// Live Event Host #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LiveEventHost { @@ -137,8 +533,14 @@ pub struct LiveEventHost { /// Live Event #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LiveEvent { - /// Unique event ID + /// Event kind + pub kind: Kind, + /// Unique event identifier (`d` tag) pub id: String, + /// Room display name + pub room: Option<String>, + /// Parent space reference + pub space: Option<LiveEventSpace>, /// Event title pub title: Option<String>, /// Event summary @@ -147,10 +549,14 @@ pub struct LiveEvent { pub image: Option<(Url, Option<ImageDimensions>)>, /// Hashtags pub hashtags: Vec<String>, - /// Steaming URL + /// Streaming URL pub streaming: Option<Url>, /// Recording URL pub recording: Option<Url>, + /// Service URL + pub service: Option<Url>, + /// Endpoint URL + pub endpoint: Option<Url>, /// Starts at pub starts: Option<Timestamp>, /// Ends at @@ -163,12 +569,20 @@ pub struct LiveEvent { pub total_participants: Option<u64>, /// Relays pub relays: Vec<RelayUrl>, + /// Pinned live chat messages + pub pinned: Vec<EventId>, /// Host pub host: Option<LiveEventHost>, + /// Owners + pub owners: Vec<(PublicKey, Option<RelayUrl>)>, + /// Moderators + pub moderators: Vec<(PublicKey, Option<RelayUrl>)>, /// Speakers pub speakers: Vec<(PublicKey, Option<RelayUrl>)>, /// Participants pub participants: Vec<(PublicKey, Option<RelayUrl>)>, + /// Hand raised flag for presence events + pub hand: Option<bool>, } impl LiveEvent { @@ -178,22 +592,103 @@ impl LiveEvent { S: Into<String>, { Self { + kind: Kind::LiveEvent, id: id.into(), + room: None, + space: None, title: None, summary: None, image: None, hashtags: Vec::new(), streaming: None, recording: None, + service: None, + endpoint: None, starts: None, ends: None, status: None, current_participants: None, total_participants: None, relays: Vec::new(), + pinned: Vec::new(), host: None, + owners: Vec::new(), + moderators: Vec::new(), speakers: Vec::new(), participants: Vec::new(), + hand: None, + } + } + + /// Parse live-activity data from an [`Event`]. + pub fn from_event(event: &Event) -> Result<Self, Error> { + let id = if event.kind == Kind::Custom(10312) { + String::new() + } else { + event.tags.identifier().ok_or(Error::DescriptionMissing)? + }; + + let mut live_event = Self::new(id); + live_event.kind = event.kind; + + for tag in event.tags.iter() { + let parsed = match Nip53Tag::try_from(tag) { + Ok(tag) => tag, + Err(Error::Codec(TagCodecError::Unknown)) => continue, + Err(err) => return Err(err), + }; + + live_event.apply_tag(parsed); + } + + Ok(live_event) + } + + fn apply_tag(&mut self, tag: Nip53Tag) { + match tag { + Nip53Tag::Title(title) => self.title = Some(title), + Nip53Tag::Summary(summary) => self.summary = Some(summary), + Nip53Tag::Image(image, dim) => self.image = Some((image, dim)), + Nip53Tag::Hashtag(hashtag) => self.hashtags.push(hashtag), + Nip53Tag::Streaming(url) => self.streaming = Some(url), + Nip53Tag::Recording(url) => self.recording = Some(url), + Nip53Tag::Starts(starts) => self.starts = Some(starts), + Nip53Tag::Ends(ends) => self.ends = Some(ends), + Nip53Tag::Status(status) => self.status = Some(status), + Nip53Tag::CurrentParticipants(count) => self.current_participants = Some(count), + Nip53Tag::TotalParticipants(count) => self.total_participants = Some(count), + Nip53Tag::Relays(mut relays) => self.relays.append(&mut relays), + Nip53Tag::Participant(participant) => match participant.marker { + LiveEventMarker::Host => { + self.host = Some(LiveEventHost { + public_key: participant.public_key, + relay_url: participant.relay_url, + proof: participant.proof, + }); + } + LiveEventMarker::Owner => { + self.owners + .push((participant.public_key, participant.relay_url)); + } + LiveEventMarker::Moderator => { + self.moderators + .push((participant.public_key, participant.relay_url)); + } + LiveEventMarker::Speaker => { + self.speakers + .push((participant.public_key, participant.relay_url)); + } + LiveEventMarker::Participant | LiveEventMarker::Custom(_) => { + self.participants + .push((participant.public_key, participant.relay_url)); + } + }, + Nip53Tag::Room(room) => self.room = Some(room), + Nip53Tag::Service(service) => self.service = Some(service), + Nip53Tag::Endpoint(endpoint) => self.endpoint = Some(endpoint), + Nip53Tag::Pinned(event_id) => self.pinned.push(event_id), + Nip53Tag::Space(space) => self.space = Some(space), + Nip53Tag::Hand(hand) => self.hand = Some(hand), } } } @@ -201,110 +696,174 @@ impl LiveEvent { impl From<LiveEvent> for Vec<Tag> { fn from(live_event: LiveEvent) -> Self { let LiveEvent { + kind: _, id, + room, + space, title, summary, image, hashtags, streaming, recording, + service, + endpoint, starts, ends, status, current_participants, total_participants, relays, + pinned, host, + owners, + moderators, speakers, participants, + hand, } = live_event; let mut tags = Vec::with_capacity(1); - tags.push(Tag::identifier(id)); + if !id.is_empty() { + tags.push(Tag::identifier(id)); + } - if let Some(title) = title { - tags.push(Tag::from_standardized(TagStandard::Title(title))); + if let Some(room) = room { + tags.push(Nip53Tag::Room(room).to_tag()); } - if let Some(summary) = summary { - tags.push(Tag::from_standardized(TagStandard::Summary(summary))); + if let Some(space) = space { + tags.push(Nip53Tag::Space(space).to_tag()); } - if let Some(streaming) = streaming { - tags.push(Tag::from_standardized(TagStandard::Streaming(streaming))); + if let Some(title) = title { + tags.push(Nip53Tag::Title(title).to_tag()); } - if let Some(status) = status { - tags.push(Tag::from_standardized(TagStandard::LiveEventStatus(status))); + if let Some(summary) = summary { + tags.push(Nip53Tag::Summary(summary).to_tag()); } - if let Some(LiveEventHost { - public_key, - relay_url, - proof, - }) = host - { - tags.push(Tag::from_standardized(TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker: LiveEventMarker::Host, - proof, - })); + if let Some((image, dim)) = image { + tags.push(Nip53Tag::Image(image, dim).to_tag()); } - for (public_key, relay_url) in speakers.into_iter() { - tags.push(Tag::from_standardized(TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker: LiveEventMarker::Speaker, - proof: None, - })); + for hashtag in hashtags.into_iter() { + tags.push(Nip53Tag::Hashtag(hashtag).to_tag()); } - for (public_key, relay_url) in participants.into_iter() { - tags.push(Tag::from_standardized(TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker: LiveEventMarker::Participant, - proof: None, - })); + if let Some(streaming) = streaming { + tags.push(Nip53Tag::Streaming(streaming).to_tag()); } - if let Some((image, dim)) = image { - tags.push(Tag::from_standardized(TagStandard::Image(image, dim))); + if let Some(recording) = recording { + tags.push(Nip53Tag::Recording(recording).to_tag()); } - for hashtag in hashtags.into_iter() { - tags.push(Tag::from_standardized(TagStandard::Hashtag(hashtag))); + if let Some(service) = service { + tags.push(Nip53Tag::Service(service).to_tag()); } - if let Some(recording) = recording { - tags.push(Tag::from_standardized(TagStandard::Recording(recording))); + if let Some(endpoint) = endpoint { + tags.push(Nip53Tag::Endpoint(endpoint).to_tag()); } if let Some(starts) = starts { - tags.push(Tag::from_standardized(TagStandard::Starts(starts))); + tags.push(Nip53Tag::Starts(starts).to_tag()); } if let Some(ends) = ends { - tags.push(Tag::from_standardized(TagStandard::Ends(ends))); + tags.push(Nip53Tag::Ends(ends).to_tag()); + } + + if let Some(status) = status { + tags.push(Nip53Tag::Status(status).to_tag()); } if let Some(current_participants) = current_participants { - tags.push(Tag::from_standardized(TagStandard::CurrentParticipants( - current_participants, - ))); + tags.push(Nip53Tag::CurrentParticipants(current_participants).to_tag()); } if let Some(total_participants) = total_participants { - tags.push(Tag::from_standardized(TagStandard::TotalParticipants( - total_participants, - ))); + tags.push(Nip53Tag::TotalParticipants(total_participants).to_tag()); } if !relays.is_empty() { - tags.push(Tag::from_standardized(TagStandard::Relays(relays))); + tags.push(Nip53Tag::Relays(relays).to_tag()); + } + + for event_id in pinned.into_iter() { + tags.push(Nip53Tag::Pinned(event_id).to_tag()); + } + + if let Some(LiveEventHost { + public_key, + relay_url, + proof, + }) = host + { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { + public_key, + relay_url, + marker: LiveEventMarker::Host, + proof, + }) + .to_tag(), + ); + } + + for (public_key, relay_url) in owners.into_iter() { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { + public_key, + relay_url, + marker: LiveEventMarker::Owner, + proof: None, + }) + .to_tag(), + ); + } + + for (public_key, relay_url) in moderators.into_iter() { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { + public_key, + relay_url, + marker: LiveEventMarker::Moderator, + proof: None, + }) + .to_tag(), + ); + } + + for (public_key, relay_url) in speakers.into_iter() { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { + public_key, + relay_url, + marker: LiveEventMarker::Speaker, + proof: None, + }) + .to_tag(), + ); + } + + for (public_key, relay_url) in participants.into_iter() { + tags.push( + Nip53Tag::Participant(LiveEventParticipant { + public_key, + relay_url, + marker: LiveEventMarker::Participant, + proof: None, + }) + .to_tag(), + ); + } + + if let Some(hand) = hand { + tags.push(Nip53Tag::Hand(hand).to_tag()); } tags @@ -315,55 +874,176 @@ impl TryFrom<Vec<Tag>> for LiveEvent { type Error = Error; fn try_from(tags: Vec<Tag>) -> Result<Self, Self::Error> { - // Extract content of `d` tag - let id: &str = tags + let id: String = tags .iter() - .find(|t| t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D))) + .find(|t| t.kind() == TagKind::d()) .and_then(|t| t.content()) + .map(|value| value.to_string()) .ok_or(Error::DescriptionMissing)?; let mut live_event = LiveEvent::new(id); for tag in tags.into_iter() { - let Some(tag) = tag.standardized() else { - continue; + let parsed = match Nip53Tag::try_from(tag) { + Ok(tag) => tag, + Err(Error::Codec(TagCodecError::Unknown)) => continue, + Err(err) => return Err(err), }; - match tag { - TagStandard::Title(title) => live_event.title = Some(title), - TagStandard::Summary(summary) => live_event.summary = Some(summary), - TagStandard::Streaming(url) => live_event.streaming = Some(url), - TagStandard::LiveEventStatus(status) => live_event.status = Some(status), - TagStandard::PublicKeyLiveEvent { - public_key, - relay_url, - marker, - proof, - } => match marker { - LiveEventMarker::Host => { - live_event.host = Some(LiveEventHost { - public_key, - relay_url, - proof, - }) - } - LiveEventMarker::Speaker => live_event.speakers.push((public_key, relay_url)), - LiveEventMarker::Participant => { - live_event.participants.push((public_key, relay_url)) - } - }, - TagStandard::Image(image, dim) => live_event.image = Some((image, dim)), - TagStandard::Hashtag(hashtag) => live_event.hashtags.push(hashtag), - TagStandard::Recording(url) => live_event.recording = Some(url), - TagStandard::Starts(starts) => live_event.starts = Some(starts), - TagStandard::Ends(ends) => live_event.ends = Some(ends), - TagStandard::CurrentParticipants(n) => live_event.current_participants = Some(n), - TagStandard::TotalParticipants(n) => live_event.total_participants = Some(n), - TagStandard::Relays(mut relays) => live_event.relays.append(&mut relays), - _ => {} - } + live_event.apply_tag(parsed); } Ok(live_event) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::JsonUtil; + + #[test] + fn test_live_event_marker() { + assert_eq!( + LiveEventMarker::from_str("Host").unwrap(), + LiveEventMarker::Host + ); + assert_eq!( + LiveEventMarker::from_str("Moderator").unwrap(), + LiveEventMarker::Moderator + ); + assert_eq!( + LiveEventMarker::from_str("Owner").unwrap(), + LiveEventMarker::Owner + ); + assert_eq!( + LiveEventMarker::from_str("Invited").unwrap(), + LiveEventMarker::Custom(String::from("Invited")) + ); + } + + #[test] + fn test_standardized_nip53_tags() { + let service = Nip53Tag::parse(["service", "https://meet.example.com/room"]).unwrap(); + assert_eq!( + service, + Nip53Tag::Service(Url::parse("https://meet.example.com/room").unwrap()) + ); + + let pinned_id = + EventId::from_hex("97aa81798ee6c5637f7b21a411f89e10244e195aa91cb341bf49f718e36c8188") + .unwrap(); + let pinned = Nip53Tag::parse([ + "pinned", + "97aa81798ee6c5637f7b21a411f89e10244e195aa91cb341bf49f718e36c8188", + ]) + .unwrap(); + assert_eq!(pinned, Nip53Tag::Pinned(pinned_id)); + + let space = Nip53Tag::parse([ + "a", + "30312:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:main-conference-room", + "wss://nostr.example.com", + "root", + ]) + .unwrap(); + assert_eq!( + space, + Nip53Tag::Space(LiveEventSpace { + coordinate: Coordinate::new( + Kind::Custom(30312), + PublicKey::from_hex( + "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca", + ) + .unwrap(), + ) + .identifier("main-conference-room"), + relay_url: Some(RelayUrl::parse("wss://nostr.example.com").unwrap()), + marker: Some(String::from("root")), + }) + ); + + let hand = Nip53Tag::parse(["hand", "1"]).unwrap(); + assert_eq!(hand, Nip53Tag::Hand(true)); + } + + #[test] + fn test_live_event_from_event() { + let event = Event::from_json( + r#"{ + "content": "", + "created_at": 1687286726, + "id": "97aa81798ee6c5637f7b21a411f89e10244e195aa91cb341bf49f718e36c8188", + "kind": 30313, + "pubkey": "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", + "sig": "997f62ddfc0827c121043074d50cfce7a528e978c575722748629a4137c45b75bdbc84170bedc723ef0a5a4c3daebf1fef2e93f5e2ddb98e5d685d022c30b622", + "tags": [ + ["d", "annual-meeting-2025"], + ["a", "30312:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:main-conference-room", "wss://nostr.example.com"], + ["title", "Annual Company Meeting 2025"], + ["summary", "Yearly company-wide meeting"], + ["image", "https://example.com/meeting.jpg"], + ["starts", "1676262123"], + ["ends", "1676269323"], + ["status", "live"], + ["total_participants", "180"], + ["current_participants", "175"], + ["p", "91cf94e5ca91cf94e5ca91cf94e5ca91cf94e5ca91cf94e5ca91cf94e5ca91cf", "wss://provider1.com/", "Speaker"] + ] +}"#, + ) + .unwrap(); + + let live_event = LiveEvent::from_event(&event).unwrap(); + assert_eq!(live_event.kind, Kind::Custom(30313)); + assert_eq!(live_event.id, "annual-meeting-2025"); + assert_eq!( + live_event.space, + Some(LiveEventSpace { + coordinate: Coordinate::new( + Kind::Custom(30312), + PublicKey::from_hex( + "f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca", + ) + .unwrap(), + ) + .identifier("main-conference-room"), + relay_url: Some(RelayUrl::parse("wss://nostr.example.com").unwrap()), + marker: None, + }) + ); + assert_eq!( + live_event.title, + Some(String::from("Annual Company Meeting 2025")) + ); + assert_eq!(live_event.status, Some(LiveEventStatus::Live)); + assert_eq!(live_event.total_participants, Some(180)); + assert_eq!(live_event.current_participants, Some(175)); + assert_eq!(live_event.speakers.len(), 1); + } + + #[test] + fn test_room_presence_from_event() { + let event = Event::from_json( + r#"{ + "content": "", + "created_at": 1687286726, + "id": "97aa81798ee6c5637f7b21a411f89e10244e195aa91cb341bf49f718e36c8188", + "kind": 10312, + "pubkey": "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24", + "sig": "997f62ddfc0827c121043074d50cfce7a528e978c575722748629a4137c45b75bdbc84170bedc723ef0a5a4c3daebf1fef2e93f5e2ddb98e5d685d022c30b622", + "tags": [ + ["a", "30312:f7234bd4c1394dda46d09f35bd384dd30cc552ad5541990f98844fb06676e9ca:main-conference-room", "wss://nostr.example.com", "root"], + ["hand", "1"] + ] +}"#, + ) + .unwrap(); + + let presence = LiveEvent::from_event(&event).unwrap(); + assert_eq!(presence.kind, Kind::Custom(10312)); + assert!(presence.id.is_empty()); + assert_eq!(presence.hand, Some(true)); + assert_eq!(presence.space.unwrap().marker, Some(String::from("root"))); + } +} From fa482dd863e698b35d17bc6652b9ab4acb6a13f6 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Thu, 30 Apr 2026 17:03:50 +0200 Subject: [PATCH 18/40] nostr: rework NIP-98 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/kind.rs | 8 - crates/nostr/src/event/tag/standard.rs | 30 ---- crates/nostr/src/nips/nip98.rs | 212 +++++++++++++++++++------ 3 files changed, 163 insertions(+), 87 deletions(-) diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index 9db1fb30d..3f547e0d9 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -81,8 +81,6 @@ pub enum TagKind<'a> { Lnurl, /// Maintainers Maintainers, - /// HTTP Method Request - Method, /// MLS Protocol Version MlsProtocolVersion, /// MLS Cipher Suite @@ -95,8 +93,6 @@ pub enum TagKind<'a> { /// /// <https://github.com/nostr-protocol/nips/blob/master/13.md> Nonce, - /// Payload - Payload, /// Preimage Preimage, /// Proxy @@ -307,13 +303,11 @@ impl<'a> TagKind<'a> { Self::Lnurl => "lnurl", Self::Maintainers => "maintainers", Self::MergeBase => "merge-base", - Self::Method => "method", Self::MlsProtocolVersion => "mls_protocol_version", Self::MlsCiphersuite => "mls_ciphersuite", Self::MlsExtensions => "mls_extensions", Self::Name => "name", Self::Nonce => "nonce", - Self::Payload => "payload", Self::Preimage => "preimage", Self::Proxy => "proxy", Self::PublishedAt => "published_at", @@ -369,13 +363,11 @@ impl<'a> From<&'a str> for TagKind<'a> { "lnurl" => Self::Lnurl, "maintainers" => Self::Maintainers, "merge-base" => Self::MergeBase, - "method" => Self::Method, "mls_protocol_version" => Self::MlsProtocolVersion, "mls_ciphersuite" => Self::MlsCiphersuite, "mls_extensions" => Self::MlsExtensions, "name" => Self::Name, "nonce" => Self::Nonce, - "payload" => Self::Payload, "preimage" => Self::Preimage, "proxy" => Self::Proxy, "published_at" => Self::PublishedAt, diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index a6bcb3bb6..4e41b093f 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -8,8 +8,6 @@ use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::str::FromStr; -use hashes::sha256::Hash as Sha256Hash; - use super::{Error, TagKind}; use crate::event::id::EventId; use crate::nips::nip01::Coordinate; @@ -18,8 +16,6 @@ use crate::nips::nip48::Protocol; use crate::nips::nip56::Report; use crate::nips::nip73::{ExternalContentId, Nip73Kind}; use crate::nips::nip90::DataVendingMachineStatus; -#[cfg(feature = "nip98")] -use crate::nips::nip98::HttpMethod; use crate::types::{RelayUrl, Url}; use crate::{ Alphabet, Event, ImageDimensions, JsonUtil, Kind, PublicKey, SingleLetterTag, Timestamp, @@ -124,10 +120,6 @@ pub enum TagStandard { PublishedAt(Timestamp), Url(Url), Server(Url), - AbsoluteURL(Url), - #[cfg(feature = "nip98")] - Method(HttpMethod), - Payload(Sha256Hash), Anon { msg: Option<String>, }, @@ -302,10 +294,6 @@ impl TagStandard { character: Alphabet::G, uppercase: false, }) => Ok(Self::Geohash(tag_1.to_string())), - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::U, - uppercase: false, - }) => Ok(Self::AbsoluteURL(Url::parse(tag_1)?)), TagKind::Dependency => Ok(Self::Dependency(tag_1.to_string())), TagKind::Relay => Ok(Self::Relay(RelayUrl::parse(tag_1)?)), TagKind::Extension => Ok(Self::Extension(tag_1.to_string())), @@ -337,9 +325,6 @@ impl TagStandard { }), Err(_) => Err(Error::UnknownStandardizedTag), }, - #[cfg(feature = "nip98")] - TagKind::Method => Ok(Self::Method(HttpMethod::from_str(tag_1)?)), - TagKind::Payload => Ok(Self::Payload(Sha256Hash::from_str(tag_1)?)), TagKind::Request => Ok(Self::Request(Event::from_json(tag_1)?)), TagKind::Word => Ok(Self::Word(tag_1.to_string())), TagKind::Alt => Ok(Self::Alt(tag_1.to_string())), @@ -481,13 +466,6 @@ impl TagStandard { Self::Url(..) => TagKind::Url, Self::Server(..) => TagKind::Server, Self::DataVendingMachineStatus { .. } => TagKind::Status, - Self::AbsoluteURL(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::U, - uppercase: false, - }), - #[cfg(feature = "nip98")] - Self::Method(..) => TagKind::Method, - Self::Payload(..) => TagKind::Payload, Self::Anon { .. } => TagKind::Anon, Self::Proxy { .. } => TagKind::Proxy, Self::Emoji { .. } => TagKind::Emoji, @@ -657,14 +635,6 @@ impl From<TagStandard> for Vec<String> { TagStandard::Lnurl(lnurl) => vec![tag_kind, lnurl], TagStandard::Url(url) => vec![tag_kind, url.to_string()], TagStandard::Server(url) => vec![tag_kind, url.to_string()], - TagStandard::AbsoluteURL(url) => { - vec![tag_kind, url.to_string()] - } - #[cfg(feature = "nip98")] - TagStandard::Method(method) => { - vec![tag_kind, method.to_string()] - } - TagStandard::Payload(p) => vec![tag_kind, p.to_string()], TagStandard::Anon { msg } => { let mut tag = vec![tag_kind]; if let Some(msg) = msg { diff --git a/crates/nostr/src/nips/nip98.rs b/crates/nostr/src/nips/nip98.rs index 69a26cf6c..9ea09a8c1 100644 --- a/crates/nostr/src/nips/nip98.rs +++ b/crates/nostr/src/nips/nip98.rs @@ -2,13 +2,14 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP98: HTTP Auth +//! NIP-98: HTTP Auth //! //! This NIP defines an ephemeral event used to authorize requests to HTTP servers using nostr events. //! This is useful for HTTP services which are build for Nostr and deal with Nostr user accounts. //! //! <https://github.com/nostr-protocol/nips/blob/master/98.md> +use alloc::string::{String, ToString}; use alloc::vec::Vec; use core::fmt; use core::str::FromStr; @@ -17,31 +18,38 @@ use core::str::FromStr; use base64::engine::{Engine, general_purpose}; #[cfg(feature = "std")] use hashes::Hash; +use hashes::hex::HexToArrayError; use hashes::sha256::Hash as Sha256Hash; +use super::util::take_and_parse_from_str; +use crate::Url; #[cfg(all(feature = "std", feature = "rand"))] use crate::event::EventBuilder; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; #[cfg(feature = "std")] use crate::event::{self, Event, builder}; #[cfg(all(feature = "std", feature = "rand"))] use crate::signer::{AsyncGetPublicKey, AsyncSignEvent}; +use crate::types::url; #[cfg(feature = "std")] use crate::util::JsonUtil; #[cfg(feature = "std")] -use crate::{Kind, PublicKey, TagKind, Timestamp}; -use crate::{Tag, TagStandard, Url}; +use crate::{Kind, PublicKey, Timestamp}; #[cfg(feature = "std")] const AUTH_HEADER_PREFIX: &str = "Nostr"; +const ABSOLUTE_URL: &str = "u"; +const METHOD: &str = "method"; +const PAYLOAD: &str = "payload"; /// [`HttpData`] required tags #[derive(Debug, PartialEq, Eq)] pub enum RequiredTags { - /// [`TagStandard::AbsoluteURL`] + /// `u` AbsoluteURL, - /// [`TagStandard::Method`] + /// `method` Method, - /// [`TagStandard::Payload`] + /// `payload` Payload, } @@ -67,6 +75,12 @@ pub enum Error { /// Event builder error #[cfg(feature = "std")] EventBuilder(builder::Error), + /// URL parse error + Url(url::ParseError), + /// Hex decoding error + Hex(HexToArrayError), + /// Codec error + Codec(TagCodecError), /// Tag missing when parsing MissingTag(RequiredTags), /// Invalid HTTP Method @@ -116,6 +130,9 @@ impl fmt::Display for Error { Self::Event(e) => e.fmt(f), #[cfg(feature = "std")] Self::EventBuilder(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Hex(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::MissingTag(tag) => write!(f, "missing '{tag}' tag"), Self::UnknownMethod => f.write_str("Unknown HTTP method"), #[cfg(feature = "std")] @@ -150,6 +167,12 @@ impl fmt::Display for Error { } } +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + #[cfg(feature = "std")] impl From<base64::DecodeError> for Error { fn from(e: base64::DecodeError) -> Self { @@ -171,6 +194,18 @@ impl From<builder::Error> for Error { } } +impl From<HexToArrayError> for Error { + fn from(e: HexToArrayError) -> Self { + Self::Hex(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// HTTP Method /// /// <https://github.com/nostr-protocol/nips/blob/master/98.md> @@ -231,6 +266,59 @@ pub struct HttpData { pub payload: Option<Sha256Hash>, } +/// NIP-98 tags +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip98Tag { + /// `u` tag + AbsoluteURL(Url), + /// `method` tag + Method(HttpMethod), + /// `payload` tag + Payload(Sha256Hash), +} + +impl TagCodec for Nip98Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + ABSOLUTE_URL => { + let url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "absolute URL")?; + Ok(Self::AbsoluteURL(url)) + } + METHOD => { + let method: HttpMethod = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "method")?; + Ok(Self::Method(method)) + } + PAYLOAD => { + let payload: Sha256Hash = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "payload")?; + Ok(Self::Payload(payload)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::AbsoluteURL(url) => Tag::new(vec![String::from(ABSOLUTE_URL), url.to_string()]), + Self::Method(method) => Tag::new(vec![String::from(METHOD), method.to_string()]), + Self::Payload(payload) => Tag::new(vec![String::from(PAYLOAD), payload.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip98Tag); + impl HttpData { /// New [`HttpData`] #[inline] @@ -272,11 +360,11 @@ impl From<HttpData> for Vec<Tag> { } = data; let mut tags: Vec<Tag> = vec![ - Tag::from_standardized(TagStandard::AbsoluteURL(url)), - Tag::from_standardized(TagStandard::Method(method)), + Nip98Tag::AbsoluteURL(url).to_tag(), + Nip98Tag::Method(method).to_tag(), ]; if let Some(payload) = payload { - tags.push(Tag::from_standardized(TagStandard::Payload(payload))); + tags.push(Nip98Tag::Payload(payload).to_tag()); } tags @@ -287,28 +375,22 @@ impl TryFrom<Vec<Tag>> for HttpData { type Error = Error; fn try_from(value: Vec<Tag>) -> Result<Self, Self::Error> { - let url = value - .iter() - .find_map(|t| match t.standardized() { - Some(TagStandard::AbsoluteURL(u)) => Some(u), - _ => None, - }) - .ok_or(Error::MissingTag(RequiredTags::AbsoluteURL))?; - let method = value - .iter() - .find_map(|t| match t.standardized() { - Some(TagStandard::Method(m)) => Some(m), - _ => None, - }) - .ok_or(Error::MissingTag(RequiredTags::Method))?; - let payload = value.iter().find_map(|t| match t.standardized() { - Some(TagStandard::Payload(p)) => Some(p), - _ => None, - }); + let mut url: Option<Url> = None; + let mut method: Option<HttpMethod> = None; + let mut payload: Option<Sha256Hash> = None; + + for tag in value.into_iter() { + match Nip98Tag::try_from(tag) { + Ok(Nip98Tag::AbsoluteURL(value)) => url = Some(value), + Ok(Nip98Tag::Method(value)) => method = Some(value), + Ok(Nip98Tag::Payload(value)) => payload = Some(value), + Err(_) => (), + } + } Ok(Self { - url, - method, + url: url.ok_or(Error::MissingTag(RequiredTags::AbsoluteURL))?, + method: method.ok_or(Error::MissingTag(RequiredTags::Method))?, payload, }) } @@ -358,23 +440,9 @@ pub fn verify_auth_header( return Err(Error::WrongAuthHeaderKind); } - let authorized_url: Url = event - .tags - .find_standardized(TagKind::u()) - .and_then(|tag| match tag { - TagStandard::AbsoluteURL(u) => Some(u), - _ => None, - }) - .ok_or(Error::MissingTag(RequiredTags::AbsoluteURL))?; - - let authorized_method: HttpMethod = event - .tags - .find_standardized(TagKind::Method) - .and_then(|tag| match tag { - TagStandard::Method(u) => Some(u), - _ => None, - }) - .ok_or(Error::MissingTag(RequiredTags::Method))?; + let http_data = HttpData::try_from(event.tags.iter().cloned().collect::<Vec<Tag>>())?; + let authorized_url: Url = http_data.url; + let authorized_method: HttpMethod = http_data.method; if &authorized_url != url || authorized_method != method { return Err(Error::AuthorizationNotMatchRequest { @@ -397,9 +465,9 @@ pub fn verify_auth_header( if let Some(body_data) = body { // Get payload hash - let payload: Sha256Hash = match event.tags.find_standardized(TagKind::Payload) { - Some(TagStandard::Payload(p)) => p, - _ => return Err(Error::MissingTag(RequiredTags::Payload)), + let payload: Sha256Hash = match http_data.payload { + Some(p) => p, + None => return Err(Error::MissingTag(RequiredTags::Payload)), }; // Hash body data @@ -446,6 +514,52 @@ impl TimeDelta { mod tests { use super::*; + #[test] + fn test_nip98_tag_codec() { + let url = Nip98Tag::parse(["u", "https://example.com/"]).unwrap(); + assert_eq!( + url, + Nip98Tag::AbsoluteURL(Url::parse("https://example.com/").unwrap()) + ); + assert_eq!( + url.to_tag(), + Tag::parse(["u", "https://example.com/"]).unwrap() + ); + + let method = Nip98Tag::parse(["method", "GET"]).unwrap(); + assert_eq!(method, Nip98Tag::Method(HttpMethod::GET)); + assert_eq!(method.to_tag(), Tag::parse(["method", "GET"]).unwrap()); + + let payload_hash = Sha256Hash::from_str( + "12f8ff0f5f6f023a4ae796a5f5f6d9030434bf2b9bb7a2f4f0f0f971b3e5d79f", + ) + .unwrap(); + let payload = Nip98Tag::parse([ + "payload", + "12f8ff0f5f6f023a4ae796a5f5f6d9030434bf2b9bb7a2f4f0f0f971b3e5d79f", + ]) + .unwrap(); + assert_eq!(payload, Nip98Tag::Payload(payload_hash)); + } + + #[test] + fn test_nip98_http_data_round_trip() { + let payload = Sha256Hash::from_str( + "12f8ff0f5f6f023a4ae796a5f5f6d9030434bf2b9bb7a2f4f0f0f971b3e5d79f", + ) + .unwrap(); + let data = HttpData::new( + Url::parse("https://example.com/").unwrap(), + HttpMethod::POST, + ) + .payload(payload); + + let tags: Vec<Tag> = data.clone().into(); + let parsed = HttpData::try_from(tags).unwrap(); + + assert_eq!(parsed, data); + } + #[test] fn empty_auth_header() { let url = Url::parse("https://example.com/").unwrap(); From 6ef7cf75c488929b013c820ac12f85af6855dfdd Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Fri, 1 May 2026 10:41:22 +0200 Subject: [PATCH 19/40] nostr: rework NIP-57 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 14 +- crates/nostr/src/event/tag/kind.rs | 11 - crates/nostr/src/event/tag/standard.rs | 25 -- crates/nostr/src/nips/nip57.rs | 348 ++++++++++++++++++++++++- 4 files changed, 338 insertions(+), 60 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 04f838019..518cf5949 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -884,15 +884,13 @@ impl EventBuilder { S2: Into<String>, { let mut tags: Vec<Tag> = vec![ - Tag::from_standardized(TagStandard::Bolt11(bolt11.into())), - Tag::from_standardized(TagStandard::Description(zap_request.as_json())), + Nip57Tag::Bolt11(bolt11.into()).to_tag(), + Nip57Tag::Description(zap_request.as_json()).to_tag(), ]; // add preimage tag if provided if let Some(pre_image_tag) = preimage { - tags.push(Tag::from_standardized(TagStandard::Preimage( - pre_image_tag.into(), - ))) + tags.push(Nip57Tag::Preimage(pre_image_tag.into()).to_tag()) } // add e tag @@ -944,11 +942,7 @@ impl EventBuilder { } // add P tag - tags.push(Tag::from_standardized(TagStandard::PublicKey { - public_key: zap_request.pubkey, - relay_url: None, - uppercase: true, - })); + tags.push(Nip57Tag::Sender(zap_request.pubkey).to_tag()); Self::new(Kind::ZapReceipt, "").tags(tags) } diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index 3f547e0d9..6691e2158 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -21,8 +21,6 @@ pub enum TagKind<'a> { Alt, /// Amount Amount, - /// Anonymous - Anon, /// Bolt11 invoice Bolt11, /// Challenge @@ -77,8 +75,6 @@ pub enum TagKind<'a> { /// /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> License, - /// Lnurl - Lnurl, /// Maintainers Maintainers, /// MLS Protocol Version @@ -282,7 +278,6 @@ impl<'a> TagKind<'a> { match self { Self::Alt => "alt", Self::Amount => "amount", - Self::Anon => "anon", Self::Bolt11 => "bolt11", Self::BranchName => "branch-name", Self::Challenge => "challenge", @@ -300,7 +295,6 @@ impl<'a> TagKind<'a> { Self::Head => "HEAD", Self::Image => "image", Self::License => "license", - Self::Lnurl => "lnurl", Self::Maintainers => "maintainers", Self::MergeBase => "merge-base", Self::MlsProtocolVersion => "mls_protocol_version", @@ -343,7 +337,6 @@ impl<'a> From<&'a str> for TagKind<'a> { match kind { "alt" => Self::Alt, "amount" => Self::Amount, - "anon" => Self::Anon, "bolt11" => Self::Bolt11, "branch-name" => Self::BranchName, "challenge" => Self::Challenge, @@ -360,7 +353,6 @@ impl<'a> From<&'a str> for TagKind<'a> { "file" => Self::File, "image" => Self::Image, "license" => Self::License, - "lnurl" => Self::Lnurl, "maintainers" => Self::Maintainers, "merge-base" => Self::MergeBase, "mls_protocol_version" => Self::MlsProtocolVersion, @@ -424,9 +416,6 @@ mod tests { assert_eq!(TagKind::from("amount"), TagKind::Amount); assert_eq!(TagKind::Amount.as_str(), "amount"); - assert_eq!(TagKind::from("anon"), TagKind::Anon); - assert_eq!(TagKind::Anon.as_str(), "anon"); - assert_eq!(TagKind::from("clone"), TagKind::Clone); assert_eq!(TagKind::Clone.as_str(), "clone"); diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 4e41b093f..07f5fa559 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -115,14 +115,10 @@ pub enum TagStandard { millisats: u64, bolt11: Option<String>, }, - Lnurl(String), Name(String), PublishedAt(Timestamp), Url(Url), Server(Url), - Anon { - msg: Option<String>, - }, Proxy { id: String, protocol: Protocol, @@ -261,11 +257,6 @@ impl TagStandard { } _ => (), // Covered later }, - TagKind::Anon => { - return Ok(Self::Anon { - msg: extract_optional_string(tag, 1).map(|s| s.to_string()), - }); - } TagKind::Client => return parse_client_tag(tag), TagKind::ContentWarning => { return Ok(Self::ContentWarning { @@ -315,7 +306,6 @@ impl TagStandard { millisats: tag_1.parse()?, bolt11: None, }), - TagKind::Lnurl => Ok(Self::Lnurl(tag_1.to_string())), TagKind::Name => Ok(Self::Name(tag_1.to_string())), TagKind::Url => Ok(Self::Url(Url::parse(tag_1)?)), TagKind::Status => match DataVendingMachineStatus::from_str(tag_1) { @@ -462,11 +452,9 @@ impl TagStandard { Self::License(..) => TagKind::License, Self::Runtime(..) => TagKind::Runtime, Self::Repository(..) => TagKind::Repository, - Self::Lnurl(..) => TagKind::Lnurl, Self::Url(..) => TagKind::Url, Self::Server(..) => TagKind::Server, Self::DataVendingMachineStatus { .. } => TagKind::Status, - Self::Anon { .. } => TagKind::Anon, Self::Proxy { .. } => TagKind::Proxy, Self::Emoji { .. } => TagKind::Emoji, Self::Encrypted => TagKind::Encrypted, @@ -632,16 +620,8 @@ impl From<TagStandard> for Vec<String> { TagStandard::License(license) => vec![tag_kind, license], TagStandard::Runtime(runtime) => vec![tag_kind, runtime], TagStandard::Repository(repo) => vec![tag_kind, repo], - TagStandard::Lnurl(lnurl) => vec![tag_kind, lnurl], TagStandard::Url(url) => vec![tag_kind, url.to_string()], TagStandard::Server(url) => vec![tag_kind, url.to_string()], - TagStandard::Anon { msg } => { - let mut tag = vec![tag_kind]; - if let Some(msg) = msg { - tag.push(msg); - } - tag - } TagStandard::Proxy { id, protocol } => { vec![tag_kind, id, protocol.to_string()] } @@ -1282,11 +1262,6 @@ mod tests { TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()).to_vec() ); - assert_eq!( - vec!["lnurl", "lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp"], - TagStandard::Lnurl(String::from("lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp")).to_vec(), - ); - assert_eq!( vec!["L", "#t"], TagStandard::LabelNamespace("#t".to_string()).to_vec() diff --git a/crates/nostr/src/nips/nip57.rs b/crates/nostr/src/nips/nip57.rs index e05923ee1..a08d83bf0 100644 --- a/crates/nostr/src/nips/nip57.rs +++ b/crates/nostr/src/nips/nip57.rs @@ -2,13 +2,15 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP57: Lightning Zaps +//! NIP-57: Lightning Zaps //! //! <https://github.com/nostr-protocol/nips/blob/master/57.md> use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; use aes::Aes256; #[cfg(feature = "rand")] @@ -33,14 +35,17 @@ use rand::{CryptoRng, RngCore}; use secp256k1::{Secp256k1, Signing, Verification}; use super::nip01::Coordinate; +use super::util::{ + take_and_parse_from_str, take_and_parse_optional_from_str, take_optional_string, + take_public_key, take_relay_url, take_string, +}; #[cfg(all(feature = "std", feature = "os-rng"))] use crate::SECP256K1; use crate::event::builder::Error as BuilderError; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; use crate::key::Error as KeyError; -use crate::{ - Event, EventId, JsonUtil, PublicKey, RelayUrl, SecretKey, Tag, TagStandard, Timestamp, event, - util, -}; +use crate::types::url; +use crate::{Event, EventId, JsonUtil, PublicKey, RelayUrl, SecretKey, Timestamp, event, util}; #[cfg(feature = "rand")] use crate::{EventBuilder, Keys, Kind}; @@ -50,6 +55,15 @@ type Aes256CbcDec = Decryptor<Aes256>; const PRIVATE_ZAP_MSG_BECH32_PREFIX: Hrp = Hrp::parse_unchecked("pzap"); const PRIVATE_ZAP_IV_BECH32_PREFIX: Hrp = Hrp::parse_unchecked("iv"); +const ANON: &str = "anon"; +const AMOUNT: &str = "amount"; +const BOLT11: &str = "bolt11"; +const DESCRIPTION: &str = "description"; +const LNURL: &str = "lnurl"; +const PREIMAGE: &str = "preimage"; +const RELAYS: &str = "relays"; +const SENDER: &str = "P"; +const ZAP: &str = "zap"; #[allow(missing_docs)] #[derive(Debug)] @@ -57,8 +71,12 @@ pub enum Error { Key(KeyError), Builder(BuilderError), Event(event::Error), + Url(url::Error), + ParseInt(ParseIntError), Bech32Decode(bech32::DecodeError), Bech32Encode(bech32::EncodeError), + /// Codec error + Codec(TagCodecError), InvalidPrivateZapMessage, PrivateZapMessageNotFound, /// Wrong prefix or variant @@ -75,8 +93,11 @@ impl fmt::Display for Error { Self::Key(e) => e.fmt(f), Self::Builder(e) => e.fmt(f), Self::Event(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), Self::Bech32Decode(e) => e.fmt(f), Self::Bech32Encode(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidPrivateZapMessage => f.write_str("Invalid private zap message"), Self::PrivateZapMessageNotFound => f.write_str("Private zap message not found"), Self::WrongBech32Prefix => f.write_str("Wrong bech32 prefix"), @@ -105,6 +126,18 @@ impl From<event::Error> for Error { } } +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + impl From<bech32::DecodeError> for Error { fn from(e: bech32::DecodeError) -> Self { Self::Bech32Decode(e) @@ -117,6 +150,181 @@ impl From<bech32::EncodeError> for Error { } } +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-57 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/57.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip57Tag { + /// `relays` tag + Relays(Vec<RelayUrl>), + /// `amount` tag + Amount { + /// Amount in millisats + millisats: u64, + /// Optional bolt11 invoice + bolt11: Option<String>, + }, + /// `lnurl` tag + Lnurl(String), + /// `anon` tag + Anon { + /// Optional private zap payload + msg: Option<String>, + }, + /// `bolt11` tag + Bolt11(String), + /// `description` tag + Description(String), + /// `preimage` tag + Preimage(String), + /// `P` tag + Sender(PublicKey), + /// `zap` tag + Zap { + /// Receiver public key + public_key: PublicKey, + /// Relay used to fetch the receiver metadata + relay_url: RelayUrl, + /// Optional split weight + weight: Option<u64>, + }, +} + +impl TagCodec for Nip57Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + RELAYS => Ok(Self::Relays(parse_relays(iter)?)), + AMOUNT => { + let (millisats, bolt11) = parse_amount_tag(iter)?; + Ok(Self::Amount { millisats, bolt11 }) + } + LNURL => Ok(Self::Lnurl(take_string(&mut iter, "LNURL")?)), + ANON => Ok(Self::Anon { + msg: take_optional_string(&mut iter), + }), + BOLT11 => Ok(Self::Bolt11(take_string(&mut iter, "BOLT11")?)), + DESCRIPTION => Ok(Self::Description(take_string(&mut iter, "description")?)), + PREIMAGE => Ok(Self::Preimage(take_string(&mut iter, "preimage")?)), + SENDER => { + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + Ok(Self::Sender(public_key)) + } + ZAP => { + let (public_key, relay_url, weight) = parse_zap_tag(iter)?; + Ok(Self::Zap { + public_key, + relay_url, + weight, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Relays(relays) => { + let mut tag: Vec<String> = Vec::with_capacity(relays.len() + 1); + tag.push(String::from(RELAYS)); + tag.extend(relays.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::Amount { millisats, bolt11 } => { + let mut tag: Vec<String> = vec![String::from(AMOUNT), millisats.to_string()]; + if let Some(bolt11) = bolt11 { + tag.push(bolt11.clone()); + } + Tag::new(tag) + } + Self::Lnurl(lnurl) => Tag::new(vec![String::from(LNURL), lnurl.clone()]), + Self::Anon { msg } => { + let mut tag: Vec<String> = vec![String::from(ANON)]; + if let Some(msg) = msg { + tag.push(msg.clone()); + } + Tag::new(tag) + } + Self::Bolt11(bolt11) => Tag::new(vec![String::from(BOLT11), bolt11.clone()]), + Self::Description(description) => { + Tag::new(vec![String::from(DESCRIPTION), description.clone()]) + } + Self::Preimage(preimage) => Tag::new(vec![String::from(PREIMAGE), preimage.clone()]), + Self::Sender(public_key) => Tag::new(vec![String::from(SENDER), public_key.to_hex()]), + Self::Zap { + public_key, + relay_url, + weight, + } => { + let mut tag: Vec<String> = vec![ + String::from(ZAP), + public_key.to_hex(), + relay_url.to_string(), + ]; + + if let Some(weight) = weight { + tag.push(weight.to_string()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip57Tag); + +fn parse_relays<T, S>(iter: T) -> Result<Vec<RelayUrl>, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let mut relays: Vec<RelayUrl> = Vec::new(); + + for relay in iter { + relays.push(RelayUrl::parse(relay.as_ref())?); + } + + Ok(relays) +} + +fn parse_amount_tag<T, S>(mut iter: T) -> Result<(u64, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let millisats: u64 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "amount")?; + let bolt11: Option<String> = iter.next().map(|bolt11| bolt11.as_ref().to_string()); + + Ok((millisats, bolt11)) +} + +fn parse_zap_tag<T, S>(mut iter: T) -> Result<(PublicKey, RelayUrl, Option<u64>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let relay_url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + let weight: Option<u64> = take_and_parse_optional_from_str(&mut iter)?; + + Ok((public_key, relay_url, weight)) +} + /// Zap Type #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ZapType { @@ -226,7 +434,7 @@ impl From<ZapRequestData> for Vec<Tag> { let mut tags: Vec<Tag> = vec![Tag::public_key(public_key)]; if !relays.is_empty() { - tags.push(Tag::from_standardized(TagStandard::Relays(relays))); + tags.push(Nip57Tag::Relays(relays).into()); } if let Some(event_id) = event_id { @@ -238,14 +446,17 @@ impl From<ZapRequestData> for Vec<Tag> { } if let Some(amount) = amount { - tags.push(Tag::from_standardized(TagStandard::Amount { - millisats: amount, - bolt11: None, - })); + tags.push( + Nip57Tag::Amount { + millisats: amount, + bolt11: None, + } + .into(), + ); } if let Some(lnurl) = lnurl { - tags.push(Tag::from_standardized(TagStandard::Lnurl(lnurl))); + tags.push(Nip57Tag::Lnurl(lnurl).into()); } tags @@ -258,7 +469,7 @@ pub fn anonymous_zap_request(data: ZapRequestData) -> Result<Event, Error> { let keys = Keys::generate(); let message: String = data.message.clone(); let mut tags: Vec<Tag> = data.into(); - tags.push(Tag::from_standardized(TagStandard::Anon { msg: None })); + tags.push(Nip57Tag::Anon { msg: None }.into()); Ok(EventBuilder::new(Kind::ZapRequest, message) .tags(tags) .sign_with_keys(&keys)?) @@ -302,7 +513,7 @@ where // Compose event let mut tags: Vec<Tag> = data.into(); - tags.push(Tag::from_standardized(TagStandard::Anon { msg: Some(msg) })); + tags.push(Nip57Tag::Anon { msg: Some(msg) }.into()); let private_zap_keys: Keys = Keys::new_with_ctx(secp, secret_key); Ok(EventBuilder::new(Kind::ZapRequest, "") .tags(tags) @@ -354,7 +565,7 @@ where fn extract_anon_tag_message(event: &Event) -> Result<String, Error> { for tag in event.tags.iter() { - if let Some(TagStandard::Anon { msg }) = tag.standardized() { + if let Ok(Nip57Tag::Anon { msg }) = Nip57Tag::try_from(tag) { return msg.ok_or(Error::InvalidPrivateZapMessage); } } @@ -420,6 +631,115 @@ fn decrypt_private_zap_message(key: [u8; 32], private_zap_event: &Event) -> Resu mod tests { use super::*; + #[test] + fn test_relays_tag() { + let tag = vec!["relays", "wss://relay.damus.io", "wss://relay.primal.net"]; + let parsed = Nip57Tag::parse(tag).unwrap(); + + assert_eq!( + parsed, + Nip57Tag::Relays(vec![ + RelayUrl::parse("wss://relay.damus.io").unwrap(), + RelayUrl::parse("wss://relay.primal.net").unwrap(), + ]) + ); + assert_eq!( + parsed.to_tag(), + Tag::parse(["relays", "wss://relay.damus.io", "wss://relay.primal.net"]).unwrap() + ); + } + + #[test] + fn test_amount_tag() { + let tag = vec!["amount", "21000", "lnbc21u1p0test"]; + let parsed = Nip57Tag::parse(tag).unwrap(); + + assert_eq!( + parsed, + Nip57Tag::Amount { + millisats: 21000, + bolt11: Some(String::from("lnbc21u1p0test")), + } + ); + assert_eq!( + parsed.to_tag(), + Tag::parse(["amount", "21000", "lnbc21u1p0test"]).unwrap() + ); + } + + #[test] + fn test_anon_tag() { + let tag = vec!["anon", "encrypted-message"]; + let parsed = Nip57Tag::parse(tag).unwrap(); + + assert_eq!( + parsed, + Nip57Tag::Anon { + msg: Some(String::from("encrypted-message")), + } + ); + assert_eq!( + parsed.to_tag(), + Tag::parse(["anon", "encrypted-message"]).unwrap() + ); + } + + #[test] + fn test_sender_tag() { + let public_key = + PublicKey::from_hex("97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322") + .unwrap(); + let parsed = Nip57Tag::parse([ + "P", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", + ]) + .unwrap(); + + assert_eq!(parsed, Nip57Tag::Sender(public_key)); + assert_eq!( + parsed.to_tag(), + Tag::parse([ + "P", + "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322", + ]) + .unwrap() + ); + } + + #[test] + fn test_zap_tag() { + let public_key = + PublicKey::from_hex("82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2") + .unwrap(); + let relay_url = RelayUrl::parse("wss://nostr.oxtr.dev").unwrap(); + let parsed = Nip57Tag::parse([ + "zap", + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", + "wss://nostr.oxtr.dev", + "2", + ]) + .unwrap(); + + assert_eq!( + parsed, + Nip57Tag::Zap { + public_key, + relay_url, + weight: Some(2), + } + ); + assert_eq!( + parsed.to_tag(), + Tag::parse([ + "zap", + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", + "wss://nostr.oxtr.dev", + "2", + ]) + .unwrap() + ); + } + #[test] fn test_encrypt_decrypt_private_zap_message() { let alice_keys = Keys::generate(); From fcad6ea67a03fc9b0c7e3ffaba02a3305afceeef Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Fri, 1 May 2026 14:40:02 +0200 Subject: [PATCH 20/40] nostr: rework NIP-25 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/nips/nip25.rs | 123 ++++++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 16 deletions(-) diff --git a/crates/nostr/src/nips/nip25.rs b/crates/nostr/src/nips/nip25.rs index 8e7d7aaa0..ef0ee7e31 100644 --- a/crates/nostr/src/nips/nip25.rs +++ b/crates/nostr/src/nips/nip25.rs @@ -6,11 +6,85 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/25.md> -use super::nip01::Coordinate; -use super::nip22::Nip22Tag; -use crate::event::tag::{Tag, TagCodec, TagStandard, Tags}; +use alloc::string::{String, ToString}; +use core::fmt; +use core::num::ParseIntError; + +use super::nip01::{Coordinate, Nip01Tag}; +use super::util::take_and_parse_from_str; +use crate::event::tag::{Tag, TagCodec, TagCodecError, Tags, impl_tag_codec_conversions}; use crate::{Event, EventId, Kind, PublicKey, RelayUrl}; +/// NIP-25 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Failed to parse integer + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-25 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/25.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip25Tag { + /// `k` tag + Kind(Kind), +} + +impl TagCodec for Nip25Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + "k" => { + let kind: Kind = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "kind")?; + Ok(Self::Kind(kind)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Kind(kind) => Tag::new(vec![String::from("k"), kind.to_string()]), + } + } +} + +impl_tag_codec_conversions!(Nip25Tag); + /// Reaction target #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ReactionTarget { @@ -45,32 +119,35 @@ impl ReactionTarget { // Serialization order: keep the `e` and `a` tags together, followed by the `p` and other tags. - // TODO: replace with a dedicated NIP-25 tag tags.push( - Nip22Tag::Event { + Nip01Tag::Event { id: self.event_id, relay_hint: self.relay_hint.clone(), public_key: Some(self.public_key), - uppercase: false, } .to_tag(), ); if let Some(coordinate) = self.coordinate { - tags.push(Tag::coordinate(coordinate, self.relay_hint.clone())); + tags.push( + Nip01Tag::Coordinate { + coordinate, + relay_hint: self.relay_hint.clone(), + } + .to_tag(), + ); } - tags.push(Tag::from_standardized(TagStandard::PublicKey { - public_key: self.public_key, - relay_url: self.relay_hint, - uppercase: false, - })); + tags.push( + Nip01Tag::PublicKey { + public_key: self.public_key, + relay_hint: self.relay_hint, + } + .to_tag(), + ); if let Some(kind) = self.kind { - tags.push(Tag::from_standardized(TagStandard::Kind { - kind, - uppercase: false, - })); + tags.push(Nip25Tag::Kind(kind).to_tag()); } tags @@ -88,3 +165,17 @@ impl From<&Event> for ReactionTarget { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_kind_tag() { + let tag = vec!["k", "1"]; + let parsed = Nip25Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip25Tag::Kind(Kind::TextNote)); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} From 2bc42eb72317355b94cbcc469ab06f897c2987e2 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Fri, 1 May 2026 15:21:59 +0200 Subject: [PATCH 21/40] nostr: rework NIP-51 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 21 +-- crates/nostr/src/event/tag/kind.rs | 4 - crates/nostr/src/event/tag/standard.rs | 4 - crates/nostr/src/nips/nip51.rs | 223 ++++++++++++++++++++++--- 4 files changed, 206 insertions(+), 46 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 518cf5949..fd2c36932 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1521,11 +1521,8 @@ impl EventBuilder { where I: IntoIterator<Item = RelayUrl>, { - Self::new(Kind::BlockedRelays, "").tags( - relay - .into_iter() - .map(|r| Tag::from_standardized(TagStandard::Relay(r))), - ) + Self::new(Kind::BlockedRelays, "") + .tags(relay.into_iter().map(|r| Nip51Tag::Relay(r).to_tag())) } /// Search relays @@ -1536,11 +1533,8 @@ impl EventBuilder { where I: IntoIterator<Item = RelayUrl>, { - Self::new(Kind::SearchRelays, "").tags( - relay - .into_iter() - .map(|r| Tag::from_standardized(TagStandard::Relay(r))), - ) + Self::new(Kind::SearchRelays, "") + .tags(relay.into_iter().map(|r| Nip51Tag::Relay(r).to_tag())) } /// Interests @@ -1586,11 +1580,8 @@ impl EventBuilder { { let tags: Vec<Tag> = vec![Tag::identifier(identifier)]; Self::new(Kind::RelaySet, "").tags( - tags.into_iter().chain( - relays - .into_iter() - .map(|r| Tag::from_standardized(TagStandard::Relay(r))), - ), + tags.into_iter() + .chain(relays.into_iter().map(|r| Nip51Tag::Relay(r).to_tag())), ) } diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index 6691e2158..9d9e094e7 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -127,8 +127,6 @@ pub enum TagKind<'a> { Url, /// Web Web, - /// Word - Word, /// Single letter SingleLetter(SingleLetterTag), /// Custom @@ -319,7 +317,6 @@ impl<'a> TagKind<'a> { Self::Tracker => "tracker", Self::Url => "url", Self::Web => "web", - Self::Word => "word", Self::SingleLetter(s) => s.as_str(), Self::Custom(tag) => tag.as_ref(), } @@ -378,7 +375,6 @@ impl<'a> From<&'a str> for TagKind<'a> { "tracker" => Self::Tracker, "url" => Self::Url, "web" => Self::Web, - "word" => Self::Word, k => match SingleLetterTag::from_str(k) { Ok(s) => Self::SingleLetter(s), Err(..) => Self::Custom(Cow::Borrowed(k)), diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 07f5fa559..453407ae9 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -174,7 +174,6 @@ pub enum TagStandard { Alt(String), /// List of web URLs Web(Vec<Url>), - Word(String), } impl TagStandard { @@ -316,7 +315,6 @@ impl TagStandard { Err(_) => Err(Error::UnknownStandardizedTag), }, TagKind::Request => Ok(Self::Request(Event::from_json(tag_1)?)), - TagKind::Word => Ok(Self::Word(tag_1.to_string())), TagKind::Alt => Ok(Self::Alt(tag_1.to_string())), _ => Err(Error::UnknownStandardizedTag), }; @@ -459,7 +457,6 @@ impl TagStandard { Self::Emoji { .. } => TagKind::Emoji, Self::Encrypted => TagKind::Encrypted, Self::Request(..) => TagKind::Request, - Self::Word(..) => TagKind::Word, Self::LabelNamespace(..) => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::L, uppercase: true, @@ -637,7 +634,6 @@ impl From<TagStandard> for Vec<String> { } tag } - TagStandard::Word(word) => vec![tag_kind, word], TagStandard::LabelNamespace(n) => vec![tag_kind, n], TagStandard::Label { value, namespace } => { let mut tag: Vec<String> = vec![tag_kind, value]; diff --git a/crates/nostr/src/nips/nip51.rs b/crates/nostr/src/nips/nip51.rs index 7dad19d27..66dbb2fa9 100644 --- a/crates/nostr/src/nips/nip51.rs +++ b/crates/nostr/src/nips/nip51.rs @@ -1,15 +1,140 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP51: Lists +//! NIP-51: Lists //! //! <https://github.com/nostr-protocol/nips/blob/master/51.md> -use alloc::string::String; +use alloc::string::{String, ToString}; use alloc::vec::Vec; +use core::fmt; use super::nip01::Coordinate; -use crate::{EventId, PublicKey, Tag, TagStandard, Url}; +use super::util::{take_event_id, take_public_key, take_relay_url, take_string}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, TagStandard, impl_tag_codec_conversions}; +use crate::types::url::{self, RelayUrl, Url}; +use crate::{EventId, PublicKey, event, key}; + +const WORD: &str = "word"; +const PUBLIC_KEY: &str = "p"; +const HASHTAG: &str = "t"; +const EVENT: &str = "e"; +const RELAY: &str = "relay"; + +/// NIP-51 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Event error + Event(event::Error), + /// Key error + Key(key::Error), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Event(e) => e.fmt(f), + Self::Key(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Key(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-51 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/51.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip51Tag { + /// `p` tag + PublicKey(PublicKey), + /// `t` tag + Hashtag(String), + /// `e` tag + Event(EventId), + /// `relay` tag + Relay(RelayUrl), + /// `word` tag + Word(String), +} + +impl TagCodec for Nip51Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + PUBLIC_KEY => { + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + Ok(Self::PublicKey(public_key)) + } + HASHTAG => { + let hashtag: String = take_string(&mut iter, "hashtag")?; + Ok(Self::Hashtag(hashtag.to_lowercase())) + } + EVENT => { + let event_id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + Ok(Self::Event(event_id)) + } + RELAY => { + let relay_url: RelayUrl = take_relay_url::<_, _, Error>(&mut iter)?; + Ok(Self::Relay(relay_url)) + } + WORD => Ok(Self::Word(take_string(&mut iter, "word")?)), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::PublicKey(public_key) => { + Tag::new(vec![String::from(PUBLIC_KEY), public_key.to_hex()]) + } + Self::Hashtag(hashtag) => Tag::new(vec![String::from(HASHTAG), hashtag.to_lowercase()]), + Self::Event(event_id) => Tag::new(vec![String::from(EVENT), event_id.to_hex()]), + Self::Relay(relay_url) => Tag::new(vec![String::from(RELAY), relay_url.to_string()]), + Self::Word(word) => Tag::new(vec![String::from(WORD), word.clone()]), + } + } +} + +impl_tag_codec_conversions!(Nip51Tag); /// Things the user doesn't want to see in their feeds #[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -36,15 +161,15 @@ impl From<MuteList> for Vec<Tag> { let mut tags = Vec::with_capacity(public_keys.len() + hashtags.len() + event_ids.len() + words.len()); - tags.extend(public_keys.into_iter().map(Tag::public_key)); - tags.extend(hashtags.into_iter().map(Tag::hashtag)); - tags.extend(event_ids.into_iter().map(Tag::event)); tags.extend( - words + public_keys .into_iter() - .map(TagStandard::Word) - .map(Tag::from_standardized), + .map(Nip51Tag::PublicKey) + .map(Into::into), ); + tags.extend(hashtags.into_iter().map(Nip51Tag::Hashtag).map(Into::into)); + tags.extend(event_ids.into_iter().map(Nip51Tag::Event).map(Into::into)); + tags.extend(words.into_iter().map(Nip51Tag::Word).map(Into::into)); tags } @@ -57,10 +182,6 @@ pub struct Bookmarks { pub event_ids: Vec<EventId>, /// Coordinates pub coordinate: Vec<Coordinate>, - /// Hashtags - pub hashtags: Vec<String>, - /// Urls - pub urls: Vec<Url>, } impl From<Bookmarks> for Vec<Tag> { @@ -68,21 +189,12 @@ impl From<Bookmarks> for Vec<Tag> { Bookmarks { event_ids, coordinate, - hashtags, - urls, }: Bookmarks, ) -> Self { - let mut tags = - Vec::with_capacity(event_ids.len() + coordinate.len() + hashtags.len() + urls.len()); + let mut tags = Vec::with_capacity(event_ids.len() + coordinate.len()); tags.extend(event_ids.into_iter().map(Tag::event)); tags.extend(coordinate.into_iter().map(Tag::from)); - tags.extend(hashtags.into_iter().map(Tag::hashtag)); - tags.extend( - urls.into_iter() - .map(TagStandard::Url) - .map(Tag::from_standardized), - ); tags } @@ -161,3 +273,68 @@ impl From<ArticlesCuration> for Vec<Tag> { tags } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_public_key_tag() { + let public_key = + PublicKey::from_hex("04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9") + .unwrap(); + let tag = vec![ + "p", + "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", + ]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip51Tag::PublicKey(public_key)); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_hashtag_tag() { + let tag = vec!["t", "Nostr"]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip51Tag::Hashtag(String::from("nostr"))); + assert_eq!(parsed.to_tag(), Tag::parse(["t", "nostr"]).unwrap()); + } + + #[test] + fn test_event_tag() { + let event_id = + EventId::from_hex("9ae37aa68f48645127299e9453eb5d908a0cbb6058ff340d528ed4d37c8994fb") + .unwrap(); + let tag = vec![ + "e", + "9ae37aa68f48645127299e9453eb5d908a0cbb6058ff340d528ed4d37c8994fb", + ]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip51Tag::Event(event_id)); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_word_tag() { + let tag = vec!["word", "spam"]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip51Tag::Word(String::from("spam"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_relay_tag() { + let tag = vec!["relay", "wss://relay.damus.io"]; + let parsed = Nip51Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip51Tag::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} From c64dbe06a9371874d978712fa0e92774c34b7bde Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Fri, 1 May 2026 16:56:42 +0200 Subject: [PATCH 22/40] nostr: rework NIP-30 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 17 +- crates/nostr/src/event/tag/kind.rs | 4 - crates/nostr/src/event/tag/standard.rs | 14 -- crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip30.rs | 207 +++++++++++++++++++++++++ crates/nostr/src/nips/nip51.rs | 16 +- crates/nostr/src/nips/util.rs | 11 ++ crates/nostr/src/prelude.rs | 1 + 8 files changed, 240 insertions(+), 31 deletions(-) create mode 100644 crates/nostr/src/nips/nip30.rs diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index fd2c36932..00ec2432b 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1649,13 +1649,16 @@ impl EventBuilder { I: IntoIterator<Item = (String, Url)>, { let tags: Vec<Tag> = vec![Tag::identifier(identifier)]; - Self::new(Kind::EmojiSet, "").tags( - tags.into_iter().chain( - emojis.into_iter().map(|(s, url)| { - Tag::from_standardized(TagStandard::Emoji { shortcode: s, url }) - }), - ), - ) + Self::new(Kind::EmojiSet, "").tags(tags.into_iter().chain(emojis.into_iter().map( + |(shortcode, image_url)| { + Nip30Tag::Emoji { + shortcode, + image_url, + emoji_set: None, + } + .to_tag() + }, + ))) } /// Label diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index 9d9e094e7..d4ba75bc9 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -55,8 +55,6 @@ pub enum TagKind<'a> { Dependency, /// Description Description, - /// Emoji - Emoji, /// Encrypted Encrypted, /// Expiration @@ -285,7 +283,6 @@ impl<'a> TagKind<'a> { Self::ContentWarning => "content-warning", Self::Dependency => "dep", Self::Description => "description", - Self::Emoji => "emoji", Self::Encrypted => "encrypted", Self::Expiration => "expiration", Self::Extension => "extension", @@ -343,7 +340,6 @@ impl<'a> From<&'a str> for TagKind<'a> { "content-warning" => Self::ContentWarning, "dep" => Self::Dependency, "description" => Self::Description, - "emoji" => Self::Emoji, "encrypted" => Self::Encrypted, "expiration" => Self::Expiration, "extension" => Self::Extension, diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 453407ae9..fc65864fe 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -123,12 +123,6 @@ pub enum TagStandard { id: String, protocol: Protocol, }, - Emoji { - /// Name given for the emoji, which MUST consist of only alphanumeric characters and underscores - shortcode: String, - /// URL to the corresponding image file of the emoji - url: Url, - }, Encrypted, Request(Event), DataVendingMachineStatus { @@ -341,10 +335,6 @@ impl TagStandard { id: tag_1.to_string(), protocol: Protocol::from(tag_2), }), - TagKind::Emoji => Ok(Self::Emoji { - shortcode: tag_1.to_string(), - url: Url::parse(tag_2)?, - }), TagKind::Status => match DataVendingMachineStatus::from_str(tag_1) { Ok(status) => Ok(Self::DataVendingMachineStatus { status, @@ -454,7 +444,6 @@ impl TagStandard { Self::Server(..) => TagKind::Server, Self::DataVendingMachineStatus { .. } => TagKind::Status, Self::Proxy { .. } => TagKind::Proxy, - Self::Emoji { .. } => TagKind::Emoji, Self::Encrypted => TagKind::Encrypted, Self::Request(..) => TagKind::Request, Self::LabelNamespace(..) => TagKind::SingleLetter(SingleLetterTag { @@ -622,9 +611,6 @@ impl From<TagStandard> for Vec<String> { TagStandard::Proxy { id, protocol } => { vec![tag_kind, id, protocol.to_string()] } - TagStandard::Emoji { shortcode, url } => { - vec![tag_kind, shortcode, url.to_string()] - } TagStandard::Encrypted => vec![tag_kind], TagStandard::Request(event) => vec![tag_kind, event.as_json()], TagStandard::DataVendingMachineStatus { status, extra_info } => { diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index a534039a7..0c70c2e0a 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -23,6 +23,7 @@ pub mod nip19; pub mod nip21; pub mod nip22; pub mod nip25; +pub mod nip30; pub mod nip34; pub mod nip35; pub mod nip38; diff --git a/crates/nostr/src/nips/nip30.rs b/crates/nostr/src/nips/nip30.rs new file mode 100644 index 000000000..23bc6346f --- /dev/null +++ b/crates/nostr/src/nips/nip30.rs @@ -0,0 +1,207 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-30: Custom Emoji +//! +//! <https://github.com/nostr-protocol/nips/blob/master/30.md> + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +use core::fmt; + +use super::nip01::{self, Coordinate}; +use super::util::{take_and_parse_from_str, take_and_parse_optional_coordinate, take_string}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, Url}; + +const EMOJI: &str = "emoji"; + +/// NIP-30 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// NIP-01 error + Nip01(nip01::Error), + /// URL parse error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), + /// Invalid shortcode + InvalidShortcode, +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nip01(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + Self::InvalidShortcode => f.write_str("Invalid shortcode"), + } + } +} + +impl From<nip01::Error> for Error { + fn from(e: nip01::Error) -> Self { + Self::Nip01(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-30 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/30.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip30Tag { + /// `emoji` tag + Emoji { + /// Emoji shortcode + shortcode: String, + /// URL to image + image_url: Url, + /// Optional emoji set address + emoji_set: Option<Coordinate>, + }, +} + +impl TagCodec for Nip30Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + EMOJI => { + let (shortcode, image_url, emoji_set) = parse_emoji_tag(iter)?; + Ok(Self::Emoji { + shortcode, + image_url, + emoji_set, + }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Emoji { + shortcode, + image_url, + emoji_set, + } => { + let mut tag: Vec<String> = Vec::with_capacity(3 + emoji_set.is_some() as usize); + tag.push(String::from(EMOJI)); + tag.push(shortcode.clone()); + tag.push(image_url.to_string()); + + if let Some(emoji_set) = emoji_set { + tag.push(emoji_set.to_string()); + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip30Tag); + +fn parse_emoji_tag<T, S>(mut iter: T) -> Result<(String, Url, Option<Coordinate>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let shortcode: String = take_string(&mut iter, "shortcode")?; + + if !is_valid_shortcode(&shortcode) { + return Err(Error::InvalidShortcode); + } + + let image_url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "image URL")?; + let emoji_set: Option<Coordinate> = take_and_parse_optional_coordinate(&mut iter)?; + + Ok((shortcode, image_url, emoji_set)) +} + +fn is_valid_shortcode(shortcode: &str) -> bool { + !shortcode.is_empty() + && shortcode + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Kind, PublicKey}; + + #[test] + fn test_nip30_emoji_tag() { + let image_url = Url::parse("https://example.com/emoji.png").unwrap(); + let tag = vec!["emoji", "soapbox", "https://example.com/emoji.png"]; + let parsed = Nip30Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip30Tag::Emoji { + shortcode: String::from("soapbox"), + image_url, + emoji_set: None, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_nip30_emoji_tag_with_set() { + let image_url = Url::parse("https://example.com/emoji.png").unwrap(); + let emoji_set = Coordinate::new( + Kind::EmojiSet, + PublicKey::from_hex("79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6") + .unwrap(), + ) + .identifier("blobcats"); + let tag = vec![ + "emoji", + "soapbox", + "https://example.com/emoji.png", + "30030:79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6:blobcats", + ]; + let parsed = Nip30Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip30Tag::Emoji { + shortcode: String::from("soapbox"), + image_url, + emoji_set: Some(emoji_set), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_nip30_invalid_shortcode() { + let tag = vec!["emoji", "soap box", "https://example.com/emoji.png"]; + assert_eq!(Nip30Tag::parse(&tag).unwrap_err(), Error::InvalidShortcode); + } +} diff --git a/crates/nostr/src/nips/nip51.rs b/crates/nostr/src/nips/nip51.rs index 66dbb2fa9..d1d32d5d1 100644 --- a/crates/nostr/src/nips/nip51.rs +++ b/crates/nostr/src/nips/nip51.rs @@ -10,8 +10,9 @@ use alloc::vec::Vec; use core::fmt; use super::nip01::Coordinate; +use super::nip30::Nip30Tag; use super::util::{take_event_id, take_public_key, take_relay_url, take_string}; -use crate::event::tag::{Tag, TagCodec, TagCodecError, TagStandard, impl_tag_codec_conversions}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; use crate::types::url::{self, RelayUrl, Url}; use crate::{EventId, PublicKey, event, key}; @@ -238,11 +239,14 @@ impl From<Emojis> for Vec<Tag> { fn from(Emojis { emojis, coordinate }: Emojis) -> Self { let mut tags = Vec::with_capacity(emojis.len() + coordinate.len()); - tags.extend( - emojis - .into_iter() - .map(|(s, url)| Tag::from_standardized(TagStandard::Emoji { shortcode: s, url })), - ); + tags.extend(emojis.into_iter().map(|(shortcode, image_url)| { + Nip30Tag::Emoji { + shortcode, + image_url, + emoji_set: None, + } + .to_tag() + })); tags.extend(coordinate.into_iter().map(Tag::from)); tags diff --git a/crates/nostr/src/nips/util.rs b/crates/nostr/src/nips/util.rs index b502c9c27..92e3943ed 100644 --- a/crates/nostr/src/nips/util.rs +++ b/crates/nostr/src/nips/util.rs @@ -20,6 +20,17 @@ where take_and_parse_optional(iter, PublicKey::from_hex) } +#[inline] +pub(super) fn take_and_parse_optional_coordinate<I, S>( + iter: &mut I, +) -> Result<Option<Coordinate>, nip01::Error> +where + I: Iterator<Item = S>, + S: AsRef<str>, +{ + take_and_parse_optional(iter, Coordinate::from_kpi_format) +} + #[inline] pub(super) fn take_and_parse_optional_relay_url<I, S>( iter: &mut I, diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 5f32cb80f..48a7ca8e4 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -46,6 +46,7 @@ pub use crate::nips::nip19::{self, *}; pub use crate::nips::nip21::{self, *}; pub use crate::nips::nip22::{self, *}; pub use crate::nips::nip25::{self, *}; +pub use crate::nips::nip30::{self, *}; pub use crate::nips::nip34::{self, *}; pub use crate::nips::nip35::{self, *}; pub use crate::nips::nip38::{self, *}; From 8e1cf2dac4c43649ad59dbc2ce442f70f01514d4 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 4 May 2026 09:50:46 +0200 Subject: [PATCH 23/40] nostr: rework NIP-B0 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/nips/nipb0.rs | 154 +++++++++++++++++++++++++++++++-- 1 file changed, 145 insertions(+), 9 deletions(-) diff --git a/crates/nostr/src/nips/nipb0.rs b/crates/nostr/src/nips/nipb0.rs index 7950ee3f3..753cd4fd1 100644 --- a/crates/nostr/src/nips/nipb0.rs +++ b/crates/nostr/src/nips/nipb0.rs @@ -2,15 +2,111 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIPB0: Web Bookmarks +//! NIP-B0: Web Bookmarks //! //! <https://github.com/nostr-protocol/nips/blob/master/B0.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; +use core::fmt; +use core::num::ParseIntError; -use super::nip01::Nip01Tag; -use crate::{EventBuilder, Kind, Tag, TagStandard, Timestamp}; +use super::util::{take_string, take_timestamp}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::{EventBuilder, Kind, Timestamp}; + +const URL: &str = "d"; +const PUBLISHED_AT: &str = "published_at"; +const TITLE: &str = "title"; +const HASHTAG: &str = "t"; + +/// NIP-B0 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Parse Int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-B0 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/B0.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NipB0Tag { + /// `d` tag containing the bookmarked URL without scheme + Url(String), + /// `published_at` tag + PublishedAt(Timestamp), + /// `title` tag + Title(String), + /// `t` tag + Hashtag(String), +} + +impl TagCodec for NipB0Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + URL => Ok(Self::Url(take_string(&mut iter, "URL")?)), + PUBLISHED_AT => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::PublishedAt(timestamp)) + } + TITLE => Ok(Self::Title(take_string(&mut iter, "title")?)), + HASHTAG => Ok(Self::Hashtag( + take_string(&mut iter, "hashtag")?.to_lowercase(), + )), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Url(url) => Tag::new(vec![String::from(URL), url.clone()]), + Self::PublishedAt(timestamp) => { + Tag::new(vec![String::from(PUBLISHED_AT), timestamp.to_string()]) + } + Self::Title(title) => Tag::new(vec![String::from(TITLE), title.clone()]), + Self::Hashtag(hashtag) => Tag::new(vec![String::from(HASHTAG), hashtag.to_lowercase()]), + } + } +} + +impl_tag_codec_conversions!(NipB0Tag); /// Web Bookmark #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -75,21 +171,61 @@ impl WebBookmark { /// Convert the web bookmark to an event builder #[allow(clippy::wrong_self_convention)] pub(crate) fn to_event_builder(self) -> EventBuilder { - let mut tags: Vec<Tag> = vec![Nip01Tag::Identifier(self.url).into()]; + let mut tags: Vec<Tag> = vec![NipB0Tag::Url(self.url).into()]; - let mut add_if_some = |tag: Option<TagStandard>| { + let mut add_if_some = |tag: Option<NipB0Tag>| { if let Some(tag) = tag { tags.push(tag.into()); } }; - add_if_some(self.published_at.map(TagStandard::PublishedAt)); - add_if_some(self.title.map(TagStandard::Title)); + add_if_some(self.published_at.map(NipB0Tag::PublishedAt)); + add_if_some(self.title.map(NipB0Tag::Title)); for hashtag in self.hashtags.into_iter() { - tags.push(TagStandard::Hashtag(hashtag).into()); + tags.push(NipB0Tag::Hashtag(hashtag).into()); } EventBuilder::new(Kind::WebBookmark, self.description).tags(tags) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_url_tag() { + let tag = vec!["d", "alice.blog/post"]; + let parsed = NipB0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipB0Tag::Url(String::from("alice.blog/post"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_published_at_tag() { + let tag = vec!["published_at", "1738863000"]; + let parsed = NipB0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipB0Tag::PublishedAt(Timestamp::from(1738863000))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_title_tag() { + let tag = vec!["title", "Blog insights by Alice"]; + let parsed = NipB0Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + NipB0Tag::Title(String::from("Blog insights by Alice")) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_hashtag_tag() { + let tag = vec!["t", "Insight"]; + let parsed = NipB0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipB0Tag::Hashtag(String::from("insight"))); + assert_eq!(parsed.to_tag(), Tag::parse(vec!["t", "insight"]).unwrap()); + } +} From 2bd09e03a5c62626b78ffd9a09b81b1c255b169f Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 4 May 2026 09:55:05 +0200 Subject: [PATCH 24/40] nostr: rework NIP-C0 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/nips/nipc0.rs | 216 ++++++++++++++++++++++++++++++--- 1 file changed, 199 insertions(+), 17 deletions(-) diff --git a/crates/nostr/src/nips/nipc0.rs b/crates/nostr/src/nips/nipc0.rs index 39d229480..2ce1f52e8 100644 --- a/crates/nostr/src/nips/nipc0.rs +++ b/crates/nostr/src/nips/nipc0.rs @@ -2,14 +2,126 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIPC0: Code Snippets +//! NIP-C0: Code Snippets //! //! <https://github.com/nostr-protocol/nips/blob/master/C0.md> use alloc::string::String; +use alloc::vec; use alloc::vec::Vec; +use core::fmt; -use crate::{EventBuilder, Kind, Tag, TagStandard}; +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::{EventBuilder, Kind}; + +const LANGUAGE: &str = "l"; +const NAME: &str = "name"; +const EXTENSION: &str = "extension"; +const DESCRIPTION: &str = "description"; +const RUNTIME: &str = "runtime"; +const LICENSE: &str = "license"; +const DEPENDENCY: &str = "dep"; +const REPOSITORY: &str = "repo"; + +/// NIP-C0 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(err) => err.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(err: TagCodecError) -> Self { + Self::Codec(err) + } +} + +/// Standardized NIP-C0 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/C0.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NipC0Tag { + /// `l` tag used as programming language + Language(String), + /// `name` tag + Name(String), + /// `extension` tag + Extension(String), + /// `description` tag + Description(String), + /// `runtime` tag + Runtime(String), + /// `license` tag + License(String), + /// `dep` tag + Dependency(String), + /// `repo` tag + Repository(String), +} + +impl TagCodec for NipC0Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + LANGUAGE => Ok(Self::Language( + take_string(&mut iter, "language")?.to_lowercase(), + )), + NAME => Ok(Self::Name(take_string(&mut iter, "name")?)), + EXTENSION => Ok(Self::Extension(take_string(&mut iter, "extension")?)), + DESCRIPTION => Ok(Self::Description(take_string(&mut iter, "description")?)), + RUNTIME => Ok(Self::Runtime(take_string(&mut iter, "runtime")?)), + LICENSE => Ok(Self::License(take_string(&mut iter, "license")?)), + DEPENDENCY => Ok(Self::Dependency(take_string(&mut iter, "dependency")?)), + REPOSITORY => Ok(Self::Repository(take_string(&mut iter, "repository")?)), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Language(language) => { + Tag::new(vec![String::from(LANGUAGE), language.to_lowercase()]) + } + Self::Name(name) => Tag::new(vec![String::from(NAME), name.clone()]), + Self::Extension(extension) => { + Tag::new(vec![String::from(EXTENSION), extension.clone()]) + } + Self::Description(description) => { + Tag::new(vec![String::from(DESCRIPTION), description.clone()]) + } + Self::Runtime(runtime) => Tag::new(vec![String::from(RUNTIME), runtime.clone()]), + Self::License(license) => Tag::new(vec![String::from(LICENSE), license.clone()]), + Self::Dependency(dependency) => { + Tag::new(vec![String::from(DEPENDENCY), dependency.clone()]) + } + Self::Repository(repository) => { + Tag::new(vec![String::from(REPOSITORY), repository.clone()]) + } + } + } +} + +impl_tag_codec_conversions!(NipC0Tag); /// Code snippet #[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -139,29 +251,99 @@ impl CodeSnippet { pub(crate) fn to_event_builder(self) -> EventBuilder { let mut tags: Vec<Tag> = Vec::new(); - let mut add_if_some = |tag: Option<TagStandard>| { + let mut add_if_some = |tag: Option<NipC0Tag>| { if let Some(tag) = tag { - tags.push(Tag::from_standardized(tag)); + tags.push(tag.into()); } }; - // `l` tag used for label in all event kinds except Code Snippets (1337) - // is used as the programming language - add_if_some(self.language.map(|l| TagStandard::Label { - value: l, - namespace: None, - })); - add_if_some(self.name.map(TagStandard::Name)); - add_if_some(self.extension.map(TagStandard::Extension)); - add_if_some(self.description.map(TagStandard::Description)); - add_if_some(self.runtime.map(TagStandard::Runtime)); - add_if_some(self.license.map(TagStandard::License)); - add_if_some(self.repo.map(TagStandard::Repository)); + add_if_some(self.language.map(NipC0Tag::Language)); + add_if_some(self.name.map(NipC0Tag::Name)); + add_if_some(self.extension.map(NipC0Tag::Extension)); + add_if_some(self.description.map(NipC0Tag::Description)); + add_if_some(self.runtime.map(NipC0Tag::Runtime)); + add_if_some(self.license.map(NipC0Tag::License)); + add_if_some(self.repo.map(NipC0Tag::Repository)); for dep in self.dependencies.into_iter() { - tags.push(TagStandard::Dependency(dep).into()); + tags.push(NipC0Tag::Dependency(dep).into()); } EventBuilder::new(Kind::CodeSnippet, self.snippet).tags(tags) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_language_tag() { + let tag = vec!["l", "Rust"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Language(String::from("rust"))); + assert_eq!(parsed.to_tag(), Tag::parse(vec!["l", "rust"]).unwrap()); + } + + #[test] + fn test_parse_name_tag() { + let tag = vec!["name", "hello-world.rs"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Name(String::from("hello-world.rs"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_extension_tag() { + let tag = vec!["extension", "rs"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Extension(String::from("rs"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_description_tag() { + let tag = vec!["description", "Prints Hello, Nostr!"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + NipC0Tag::Description(String::from("Prints Hello, Nostr!")) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_runtime_tag() { + let tag = vec!["runtime", "rustc 1.70.0"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Runtime(String::from("rustc 1.70.0"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_license_tag() { + let tag = vec!["license", "MIT"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::License(String::from("MIT"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_dependency_tag() { + let tag = vec!["dep", "serde"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!(parsed, NipC0Tag::Dependency(String::from("serde"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_repository_tag() { + let tag = vec!["repo", "https://github.com/nostr-protocol/nostr"]; + let parsed = NipC0Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + NipC0Tag::Repository(String::from("https://github.com/nostr-protocol/nostr")) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} From fecad2454282e27d040221a576aecf17d8a42981 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 4 May 2026 10:07:05 +0200 Subject: [PATCH 25/40] nostr: rework NIP-13 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/mod.rs | 3 +- crates/nostr/src/event/tag/standard.rs | 32 ----- crates/nostr/src/nips/nip13/mod.rs | 125 +++++++++++++++++++- crates/nostr/src/nips/nip13/multi_thread.rs | 4 +- 4 files changed, 126 insertions(+), 38 deletions(-) diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 28173cf2c..cc4a09faf 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -30,6 +30,7 @@ pub use self::list::Tags; pub use self::standard::TagStandard; use super::id::EventId; use crate::nips::nip01::{Coordinate, Nip01Tag}; +use crate::nips::nip13::Nip13Tag; use crate::nips::nip40::Nip40Tag; use crate::nips::nip56::Report; use crate::nips::nip70::Nip70Tag; @@ -298,7 +299,7 @@ impl Tag { /// <https://github.com/nostr-protocol/nips/blob/master/13.md> #[inline] pub fn pow(nonce: u128, difficulty: u8) -> Self { - Self::from_standardized(TagStandard::POW { nonce, difficulty }) + Nip13Tag::Nonce { nonce, difficulty }.to_tag() } /// Construct `["client", "<name>"]` tag diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index fc65864fe..2b8471cfe 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -83,13 +83,6 @@ pub enum TagStandard { }, Relay(RelayUrl), Relays(Vec<RelayUrl>), - /// Proof of Work - /// - /// <https://github.com/nostr-protocol/nips/blob/master/13.md> - POW { - nonce: u128, - difficulty: u8, - }, /// Client /// /// <https://github.com/nostr-protocol/nips/blob/master/89.md> @@ -319,10 +312,6 @@ impl TagStandard { let tag_2: &str = tag[2].as_ref(); return match tag_kind { - TagKind::Nonce => Ok(Self::POW { - nonce: tag_1.parse()?, - difficulty: tag_2.parse()?, - }), TagKind::Image => Ok(Self::Image( Url::parse(tag_1)?, Some(ImageDimensions::from_str(tag_2)?), @@ -419,7 +408,6 @@ impl TagStandard { }) } Self::Relay(..) => TagKind::Relay, - Self::POW { .. } => TagKind::Nonce, Self::Client { .. } => TagKind::Client, Self::ContentWarning { .. } => TagKind::ContentWarning, Self::Subject(..) => TagKind::Subject, @@ -532,9 +520,6 @@ impl From<TagStandard> for Vec<String> { TagStandard::Kind { kind, .. } => vec![tag_kind, kind.to_string()], TagStandard::Nip73Kind { kind, .. } => vec![tag_kind, kind.to_string()], TagStandard::Relay(url) => vec![tag_kind, url.to_string()], - TagStandard::POW { nonce, difficulty } => { - vec![tag_kind, nonce.to_string(), difficulty.to_string()] - } TagStandard::Client { name, address } => { let mut tag: Vec<String> = vec![tag_kind, name]; @@ -1131,15 +1116,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec!["nonce", "1", "20"], - TagStandard::POW { - nonce: 1, - difficulty: 20 - } - .to_vec() - ); - assert_eq!( vec!["client", "voyage"], TagStandard::Client { @@ -1474,14 +1450,6 @@ mod tests { ) ); - assert_eq!( - TagStandard::parse(&["nonce", "1", "20"]).unwrap(), - TagStandard::POW { - nonce: 1, - difficulty: 20 - } - ); - assert_eq!( TagStandard::parse(&["client", "voyage"]).unwrap(), TagStandard::Client { diff --git a/crates/nostr/src/nips/nip13/mod.rs b/crates/nostr/src/nips/nip13/mod.rs index 3097508eb..70a3a1395 100644 --- a/crates/nostr/src/nips/nip13/mod.rs +++ b/crates/nostr/src/nips/nip13/mod.rs @@ -7,11 +7,13 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/13.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::any::Any; +use core::fmt; use core::fmt::Debug; -use core::num::NonZeroU8; +use core::num::{NonZeroU8, ParseIntError}; #[cfg(feature = "std")] mod blocking_wrapper; @@ -22,9 +24,104 @@ mod single_thread; #[cfg(feature = "pow-multi-thread")] pub use self::multi_thread::*; pub use self::single_thread::*; +use super::util::take_and_parse_from_str; use crate::UnsignedEvent; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; use crate::util::BoxedFuture; +const NONCE: &str = "nonce"; + +/// NIP-13 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Parse Int error + ParseInt(ParseIntError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ParseInt(e) => fmt::Display::fmt(e, f), + Self::Codec(e) => fmt::Display::fmt(e, f), + } + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-13 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/13.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip13Tag { + /// `nonce` tag + Nonce { + /// Nonce + nonce: u128, + /// Target difficulty + difficulty: u8, + }, +} + +impl TagCodec for Nip13Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + NONCE => { + let (nonce, difficulty) = parse_nonce_tag(iter)?; + Ok(Self::Nonce { nonce, difficulty }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Nonce { nonce, difficulty } => Tag::new(vec![ + String::from(NONCE), + nonce.to_string(), + difficulty.to_string(), + ]), + } + } +} + +impl_tag_codec_conversions!(Nip13Tag); + +fn parse_nonce_tag<T, S>(mut iter: T) -> Result<(u128, u8), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let nonce: u128 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "nonce")?; + let difficulty: u8 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "difficulty")?; + + Ok((nonce, difficulty)) +} + /// Gets the number of leading zero bits. Result is between 0 and 255. #[inline] pub fn get_leading_zero_bits<T>(h: T) -> u8 @@ -111,8 +208,30 @@ pub mod tests { use hashes::sha256::Hash as Sha256Hash; use super::*; + use crate::Tag; #[cfg(feature = "std")] - use crate::{EventBuilder, PublicKey, Tag, TagKind}; + use crate::{EventBuilder, PublicKey, TagKind}; + + #[test] + fn test_parse_nonce_tag() { + let tag = vec!["nonce", "776797", "20"]; + let parsed = Nip13Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip13Tag::Nonce { + nonce: 776797, + difficulty: 20 + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_nonce_tag_missing_difficulty() { + let tag = vec!["nonce", "776797"]; + let err = Nip13Tag::parse(&tag).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("difficulty"))); + } #[test] fn check_get_leading_zeroes() { diff --git a/crates/nostr/src/nips/nip13/multi_thread.rs b/crates/nostr/src/nips/nip13/multi_thread.rs index 134d859ce..9bd4f77cb 100644 --- a/crates/nostr/src/nips/nip13/multi_thread.rs +++ b/crates/nostr/src/nips/nip13/multi_thread.rs @@ -132,7 +132,7 @@ pub mod tests { use std::time::Duration; use super::*; - use crate::event::{EventBuilder, TagKind}; + use crate::event::EventBuilder; use crate::key::PublicKey; use crate::nips::nip13::get_leading_zero_bits; @@ -145,7 +145,7 @@ pub mod tests { .mine(&MultiThreadPow, NonZeroU8::new(2).unwrap()) .unwrap(); - let Some(nonce_tag) = unsigned.tags.find(TagKind::Nonce) else { + let Some(nonce_tag) = unsigned.tags.iter().find(|t| t.kind() == "nonce") else { panic!("nonce tag should be exist") }; From 6a9ec5295299094a9309efe74ddc4120ec36d3d6 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 4 May 2026 10:17:48 +0200 Subject: [PATCH 26/40] nostr: rework NIP-31 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/mod.rs | 3 +- crates/nostr/src/event/tag/standard.rs | 17 ----- crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip31.rs | 100 +++++++++++++++++++++++++ crates/nostr/src/prelude.rs | 1 + 5 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 crates/nostr/src/nips/nip31.rs diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index cc4a09faf..e2a28588d 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -31,6 +31,7 @@ pub use self::standard::TagStandard; use super::id::EventId; use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip13::Nip13Tag; +use crate::nips::nip31::Nip31Tag; use crate::nips::nip40::Nip40Tag; use crate::nips::nip56::Report; use crate::nips::nip70::Nip70Tag; @@ -420,7 +421,7 @@ impl Tag { where T: Into<String>, { - Self::from_standardized(TagStandard::Alt(summary.into())) + Nip31Tag::Alt(summary.into()).to_tag() } /// Compose custom tag diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 2b8471cfe..b3fdf1b16 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -155,10 +155,6 @@ pub enum TagStandard { /// /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> Repository(String), - /// A short human-readable plaintext summary of what that event is about - /// - /// <https://github.com/nostr-protocol/nips/blob/master/31.md> - Alt(String), /// List of web URLs Web(Vec<Url>), } @@ -302,7 +298,6 @@ impl TagStandard { Err(_) => Err(Error::UnknownStandardizedTag), }, TagKind::Request => Ok(Self::Request(Event::from_json(tag_1)?)), - TagKind::Alt => Ok(Self::Alt(tag_1.to_string())), _ => Err(Error::UnknownStandardizedTag), }; } @@ -442,7 +437,6 @@ impl TagStandard { character: Alphabet::L, uppercase: false, }), - Self::Alt(..) => TagKind::Alt, Self::Web(..) => TagKind::Web, } } @@ -613,7 +607,6 @@ impl From<TagStandard> for Vec<String> { } tag } - TagStandard::Alt(summary) => vec![tag_kind, summary], TagStandard::Web(urls) => { let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); tag.push(tag_kind); @@ -985,11 +978,6 @@ mod tests { #[test] fn test_tag_standard_serialization() { - assert_eq!( - vec!["alt", "something"], - TagStandard::Alt(String::from("something")).to_vec() - ); - assert_eq!( vec!["content-warning"], TagStandard::ContentWarning { reason: None }.to_vec() @@ -1259,11 +1247,6 @@ mod tests { #[test] fn test_tag_standard_parsing() { - assert_eq!( - TagStandard::parse(&["alt", "something"]).unwrap(), - TagStandard::Alt(String::from("something")) - ); - assert_eq!( TagStandard::parse(&["content-warning"]).unwrap(), TagStandard::ContentWarning { reason: None } diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index 0c70c2e0a..b775fa3fd 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -24,6 +24,7 @@ pub mod nip21; pub mod nip22; pub mod nip25; pub mod nip30; +pub mod nip31; pub mod nip34; pub mod nip35; pub mod nip38; diff --git a/crates/nostr/src/nips/nip31.rs b/crates/nostr/src/nips/nip31.rs new file mode 100644 index 000000000..71771a31a --- /dev/null +++ b/crates/nostr/src/nips/nip31.rs @@ -0,0 +1,100 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-31: Dealing with unknown event kinds +//! +//! <https://github.com/nostr-protocol/nips/blob/master/31.md> + +use alloc::string::String; +use alloc::vec; +use core::fmt; + +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +const ALT: &str = "alt"; + +/// NIP-31 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-31 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/31.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip31Tag { + /// `alt` tag + Alt(String), +} + +impl TagCodec for Nip31Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + ALT => { + let alt: String = take_string(&mut iter, "alt value")?; + Ok(Self::Alt(alt)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Alt(value) => Tag::new(vec![String::from(ALT), value.clone()]), + } + } +} + +impl_tag_codec_conversions!(Nip31Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nip31_alt_tag() { + let tag = vec!["alt", "Something"]; + let parsed = Nip31Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip31Tag::Alt(String::from("Something"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_invalid_alt_tag_missing_value() { + let tag = vec!["alt"]; + assert_eq!( + Nip31Tag::parse(&tag).unwrap_err(), + Error::Codec(TagCodecError::Missing("alt value")) + ); + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 48a7ca8e4..8e8e2a30d 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -47,6 +47,7 @@ pub use crate::nips::nip21::{self, *}; pub use crate::nips::nip22::{self, *}; pub use crate::nips::nip25::{self, *}; pub use crate::nips::nip30::{self, *}; +pub use crate::nips::nip31::{self, *}; pub use crate::nips::nip34::{self, *}; pub use crate::nips::nip35::{self, *}; pub use crate::nips::nip38::{self, *}; From e9033f7d7e687b9b2426b44ad01ba6d27cd9bfd9 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 4 May 2026 10:49:36 +0200 Subject: [PATCH 27/40] nostr: rework NIP-56 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/mod.rs | 17 -- crates/nostr/src/event/tag/standard.rs | 143 +--------------- crates/nostr/src/nips/nip56.rs | 217 ++++++++++++++++++++++++- 3 files changed, 221 insertions(+), 156 deletions(-) diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index e2a28588d..543c1c791 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -33,7 +33,6 @@ use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip13::Nip13Tag; use crate::nips::nip31::Nip31Tag; use crate::nips::nip40::Nip40Tag; -use crate::nips::nip56::Report; use crate::nips::nip70::Nip70Tag; use crate::types::Url; use crate::{ImageDimensions, PublicKey, RelayUrl, SingleLetterTag, Timestamp}; @@ -324,22 +323,6 @@ impl Tag { Nip40Tag::Expiration(timestamp).to_tag() } - /// Compose `["e", "<event-id>", "<report>"]` tag - /// - /// <https://github.com/nostr-protocol/nips/blob/master/56.md> - #[inline] - pub fn event_report(event_id: EventId, report: Report) -> Self { - Self::from_standardized(TagStandard::EventReport(event_id, report)) - } - - /// Compose `["p", "<public-key>", "<report>"]` tag - /// - /// <https://github.com/nostr-protocol/nips/blob/master/56.md> - #[inline] - pub fn public_key_report(public_key: PublicKey, report: Report) -> Self { - Self::from_standardized(TagStandard::PublicKeyReport(public_key, report)) - } - /// Relay url /// /// JSON: `["relay", "<relay-url>"]` diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index b3fdf1b16..5464e0a20 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -13,7 +13,6 @@ use crate::event::id::EventId; use crate::nips::nip01::Coordinate; use crate::nips::nip39::Identity; use crate::nips::nip48::Protocol; -use crate::nips::nip56::Report; use crate::nips::nip73::{ExternalContentId, Nip73Kind}; use crate::nips::nip90::DataVendingMachineStatus; use crate::types::{RelayUrl, Url}; @@ -31,10 +30,6 @@ pub enum TagStandard { event_id: EventId, relay_url: Option<RelayUrl>, }, - /// Report event - /// - /// <https://github.com/nostr-protocol/nips/blob/master/56.md> - EventReport(EventId, Report), /// Public Key /// /// <https://github.com/nostr-protocol/nips/blob/master/01.md> @@ -44,10 +39,6 @@ pub enum TagStandard { /// Whether the tag is an uppercase or not uppercase: bool, }, - /// Report public key - /// - /// <https://github.com/nostr-protocol/nips/blob/master/56.md> - PublicKeyReport(PublicKey, Report), Reference(String), Hashtag(String), Geohash(String), @@ -363,15 +354,10 @@ impl TagStandard { character: Alphabet::E, uppercase: false, }), - Self::EventReport(..) => TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E)), Self::PublicKey { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::P, uppercase: *uppercase, }), - Self::PublicKeyReport(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::P, - uppercase: false, - }), Self::Reference(..) => TagKind::SingleLetter(SingleLetterTag { character: Alphabet::R, uppercase: false, @@ -479,12 +465,6 @@ impl From<TagStandard> for Vec<String> { } tag } - TagStandard::EventReport(id, report) => { - vec![tag_kind, id.to_hex(), report.to_string()] - } - TagStandard::PublicKeyReport(pk, report) => { - vec![tag_kind, pk.to_string(), report.to_string()] - } TagStandard::Reference(r) => vec![tag_kind, r], TagStandard::Hashtag(t) => vec![tag_kind, t], TagStandard::Geohash(g) => vec![tag_kind, g], @@ -640,7 +620,7 @@ where } } -fn parse_e_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> +fn parse_e_tag<S>(tag: &[S], _uppercase: bool) -> Result<TagStandard, Error> where S: AsRef<str>, { @@ -654,18 +634,6 @@ where // If this is empty, it is handled as None. let tag_2: Option<&str> = tag.get(2).map(|r| r.as_ref()).filter(|r| !r.is_empty()); - // Check if it's a report - if let Some(tag_2) = tag_2 { - if let Ok(report) = Report::from_str(tag_2) { - if uppercase { - // Uppercase report, invalid! - return Err(Error::UnknownStandardizedTag); - } - - return Ok(TagStandard::EventReport(event_id, report)); - } - } - // Parse 2nd arg let relay_url: Option<RelayUrl> = match tag_2 { Some(url) => Some(RelayUrl::parse(url)?), @@ -756,14 +724,11 @@ where uppercase, }) } else { - match Report::from_str(tag_2) { - Ok(report) => Ok(TagStandard::PublicKeyReport(public_key, report)), - Err(_) => Ok(TagStandard::PublicKey { - public_key, - relay_url: Some(RelayUrl::parse(tag_2)?), - uppercase, - }), - } + Ok(TagStandard::PublicKey { + public_key, + relay_url: Some(RelayUrl::parse(tag_2)?), + uppercase, + }) }; } @@ -1072,38 +1037,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "spam" - ], - TagStandard::PublicKeyReport( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - Report::Spam - ) - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "nudity" - ], - TagStandard::EventReport( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - Report::Nudity, - ) - .to_vec() - ); - assert_eq!( vec!["client", "voyage"], TagStandard::Client { @@ -1369,70 +1302,6 @@ mod tests { } ); - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "impersonation" - ]) - .unwrap(), - TagStandard::PublicKeyReport( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - Report::Impersonation - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "other" - ]) - .unwrap(), - TagStandard::PublicKeyReport( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - Report::Other - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "profanity" - ]) - .unwrap(), - TagStandard::EventReport( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - Report::Profanity - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "malware" - ]) - .unwrap(), - TagStandard::EventReport( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - Report::Malware - ) - ); - assert_eq!( TagStandard::parse(&["client", "voyage"]).unwrap(), TagStandard::Client { diff --git a/crates/nostr/src/nips/nip56.rs b/crates/nostr/src/nips/nip56.rs index 44ca741ec..2d1ad6de0 100644 --- a/crates/nostr/src/nips/nip56.rs +++ b/crates/nostr/src/nips/nip56.rs @@ -2,16 +2,28 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP56: Reporting +//! NIP-56: Reporting //! //! <https://github.com/nostr-protocol/nips/blob/master/56.md> +use alloc::string::{String, ToString}; +use alloc::vec; use core::fmt; use core::str::FromStr; +use super::util::{take_and_parse_from_str, take_event_id, take_public_key}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::{EventId, PublicKey, event, key}; + /// NIP56 error -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq)] pub enum Error { + /// Keys error + Keys(key::Error), + /// Event error + Event(event::Error), + /// Codec error + Codec(TagCodecError), /// Unknown [`Report`] UnknownReportType, } @@ -21,11 +33,32 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Keys(e) => e.fmt(f), + Self::Event(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::UnknownReportType => f.write_str("Unknown report type"), } } } +impl From<key::Error> for Error { + fn from(e: key::Error) -> Self { + Self::Keys(e) + } +} + +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Report /// /// <https://github.com/nostr-protocol/nips/blob/master/56.md> @@ -84,3 +117,183 @@ impl FromStr for Report { } } } + +/// Standardized NIP-56 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/56.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip56Tag { + /// `e` tag + Event { + /// Event ID + id: EventId, + /// Report + report: Report, + }, + /// `p` tag + PublicKey { + /// Public key + public_key: PublicKey, + /// Report + report: Report, + }, +} + +impl TagCodec for Nip56Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + // Take iterator + let mut iter = tag.into_iter(); + + // Extract first value + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + // Match kind + match kind.as_ref() { + "e" => { + let (id, report) = parse_e_tag(iter)?; + Ok(Self::Event { id, report }) + } + "p" => { + let (public_key, report) = parse_p_tag(iter)?; + Ok(Self::PublicKey { public_key, report }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Event { id, report } => { + Tag::new(vec![String::from("e"), id.to_hex(), report.to_string()]) + } + Self::PublicKey { public_key, report } => Tag::new(vec![ + String::from("p"), + public_key.to_hex(), + report.to_string(), + ]), + } + } +} + +impl_tag_codec_conversions!(Nip56Tag); + +fn parse_e_tag<T, S>(mut iter: T) -> Result<(EventId, Report), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let id: EventId = take_event_id::<_, _, Error>(&mut iter)?; + let report: Report = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "report")?; + + Ok((id, report)) +} + +fn parse_p_tag<T, S>(mut iter: T) -> Result<(PublicKey, Report), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let public_key: PublicKey = take_public_key::<_, _, Error>(&mut iter)?; + let report: Report = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "report")?; + + Ok((public_key, report)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_report_e_tag() { + let tag = vec![ + "e", + "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", + "nudity", + ]; + let parsed = Nip56Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip56Tag::Event { + id: EventId::from_hex( + "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" + ) + .unwrap(), + report: Report::Nudity, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_report_p_tag() { + let tag = vec![ + "p", + "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", + "impersonation", + ]; + let parsed = Nip56Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip56Tag::PublicKey { + public_key: PublicKey::from_hex( + "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" + ) + .unwrap(), + report: Report::Impersonation, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_missing_report() { + let tag = vec![ + "p", + "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", + ]; + assert!(matches!( + Nip56Tag::parse(&tag).unwrap_err(), + Error::Codec(TagCodecError::Missing("report")) + )); + + let tag = vec![ + "e", + "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", + ]; + assert!(matches!( + Nip56Tag::parse(&tag).unwrap_err(), + Error::Codec(TagCodecError::Missing("report")) + )); + } + + #[test] + fn test_empty_report() { + let tag = vec![ + "p", + "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", + "", + ]; + assert!(matches!( + Nip56Tag::parse(&tag).unwrap_err(), + Error::UnknownReportType + )); + + let tag = vec![ + "e", + "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", + "", + ]; + assert!(matches!( + Nip56Tag::parse(&tag).unwrap_err(), + Error::UnknownReportType + )); + } +} From 057c372b112bd76c85ded800e8a0e015e54edcda Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 4 May 2026 11:11:42 +0200 Subject: [PATCH 28/40] nostr: rework NIP-89 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/tag/kind.rs | 6 - crates/nostr/src/event/tag/mod.rs | 23 --- crates/nostr/src/event/tag/standard.rs | 138 ----------------- crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip89.rs | 204 +++++++++++++++++++++++++ crates/nostr/src/prelude.rs | 1 + 6 files changed, 206 insertions(+), 167 deletions(-) create mode 100644 crates/nostr/src/nips/nip89.rs diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs index d4ba75bc9..4c0222925 100644 --- a/crates/nostr/src/event/tag/kind.rs +++ b/crates/nostr/src/event/tag/kind.rs @@ -25,10 +25,6 @@ pub enum TagKind<'a> { Bolt11, /// Challenge Challenge, - /// Client - /// - /// <https://github.com/nostr-protocol/nips/blob/master/89.md> - Client, /// Clone Clone, /// Commit @@ -277,7 +273,6 @@ impl<'a> TagKind<'a> { Self::Bolt11 => "bolt11", Self::BranchName => "branch-name", Self::Challenge => "challenge", - Self::Client => "client", Self::Clone => "clone", Self::Commit => "commit", Self::ContentWarning => "content-warning", @@ -334,7 +329,6 @@ impl<'a> From<&'a str> for TagKind<'a> { "bolt11" => Self::Bolt11, "branch-name" => Self::BranchName, "challenge" => Self::Challenge, - "client" => Self::Client, "clone" => Self::Clone, "commit" => Self::Commit, "content-warning" => Self::ContentWarning, diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 543c1c791..7107030aa 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -302,19 +302,6 @@ impl Tag { Nip13Tag::Nonce { nonce, difficulty }.to_tag() } - /// Construct `["client", "<name>"]` tag - /// - /// <https://github.com/nostr-protocol/nips/blob/master/89.md> - pub fn client<S>(name: S) -> Self - where - S: Into<String>, - { - Self::from_standardized(TagStandard::Client { - name: name.into(), - address: None, - }) - } - /// Compose `["expiration", "<timestamp>"]` tag /// /// <https://github.com/nostr-protocol/nips/blob/master/40.md> @@ -654,16 +641,6 @@ mod tests { ] ) ); - - assert_eq!( - vec!["client", "rust-nostr"], - Tag::custom(TagKind::Client, ["rust-nostr"]).to_vec() - ); - - assert_eq!( - Tag::parse(["client", "nostr-sdk"]).unwrap(), - Tag::custom(TagKind::Client, ["nostr-sdk"]) - ); } #[test] diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs index 5464e0a20..f820a80ba 100644 --- a/crates/nostr/src/event/tag/standard.rs +++ b/crates/nostr/src/event/tag/standard.rs @@ -74,15 +74,6 @@ pub enum TagStandard { }, Relay(RelayUrl), Relays(Vec<RelayUrl>), - /// Client - /// - /// <https://github.com/nostr-protocol/nips/blob/master/89.md> - Client { - /// Client name - name: String, - /// Client address and optional hint - address: Option<(Coordinate, Option<RelayUrl>)>, - }, ContentWarning { reason: Option<String>, }, @@ -230,7 +221,6 @@ impl TagStandard { } _ => (), // Covered later }, - TagKind::Client => return parse_client_tag(tag), TagKind::ContentWarning => { return Ok(Self::ContentWarning { reason: extract_optional_string(tag, 1).map(|s| s.to_string()), @@ -389,7 +379,6 @@ impl TagStandard { }) } Self::Relay(..) => TagKind::Relay, - Self::Client { .. } => TagKind::Client, Self::ContentWarning { .. } => TagKind::ContentWarning, Self::Subject(..) => TagKind::Subject, Self::Challenge(..) => TagKind::Challenge, @@ -494,23 +483,6 @@ impl From<TagStandard> for Vec<String> { TagStandard::Kind { kind, .. } => vec![tag_kind, kind.to_string()], TagStandard::Nip73Kind { kind, .. } => vec![tag_kind, kind.to_string()], TagStandard::Relay(url) => vec![tag_kind, url.to_string()], - TagStandard::Client { name, address } => { - let mut tag: Vec<String> = vec![tag_kind, name]; - - match address { - Some((coordinate, Some(hint))) => { - tag.reserve_exact(2); - tag.push(coordinate.to_string()); - tag.push(hint.to_string()); - } - Some((coordinate, None)) => { - tag.push(coordinate.to_string()); - } - _ => {} - } - - tag - } TagStandard::ContentWarning { reason } => { let mut tag = vec![tag_kind]; if let Some(reason) = reason { @@ -795,51 +767,6 @@ where } } -fn parse_client_tag<S>(tag: &[S]) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // Possible cases: - // - ["client", "My Client"] - // - ["client", "My Client", "31990:app1-pubkey:<d-identifier>"] - // - ["client", "My Client", "31990:app1-pubkey:<d-identifier>", "wss://relay1"] - - // Require at least 2 values - if tag.len() < 2 { - return Err(Error::UnknownStandardizedTag); - } - - // The client name - let tag_1: &str = tag[1].as_ref(); - - // Optionally, the coordinate and the relay hint - let tag_2: Option<&str> = tag.get(2).map(|t| t.as_ref()); - let tag_3: Option<&str> = tag.get(3).map(|t| t.as_ref()); - - // Since the address is optional, - // don't return an error if the coordinate or relay hint parsing fails. - let address: Option<(Coordinate, Option<RelayUrl>)> = match tag_2 { - // Try to parse the coordinate - Some(coordinate) => match Coordinate::parse(coordinate) { - // Coordinate parsing success - Ok(coordinate) => { - let relay_url: Option<RelayUrl> = tag_3.and_then(|url| RelayUrl::parse(url).ok()); - Some((coordinate, relay_url)) - } - // Failed to parse the coordinate - Err(..) => None, - }, - // Nothing to parse - None => None, - }; - - // Construct tag - Ok(TagStandard::Client { - name: tag_1.to_string(), - address, - }) -} - #[inline] fn extract_optional_string<S>(tag: &[S], index: usize) -> Option<&str> where @@ -1037,15 +964,6 @@ mod tests { .to_vec() ); - assert_eq!( - vec!["client", "voyage"], - TagStandard::Client { - name: String::from("voyage"), - address: None - } - .to_vec() - ); - assert_eq!( vec!["dep", "nostr"], TagStandard::Dependency(String::from("nostr")).to_vec() @@ -1071,30 +989,6 @@ mod tests { TagStandard::Repository(String::from("https://github.com/rust-nostr/nostr")).to_vec() ); - assert_eq!( - vec!["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum"], - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None)) - }.to_vec() - ); - - assert_eq!( - vec!["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum"], - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None)) - }.to_vec() - ); - - assert_eq!( - vec!["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", "wss://relay.damus.io"], - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), Some(RelayUrl::parse("wss://relay.damus.io").unwrap()))) - }.to_vec() - ); - assert_eq!( vec![ "a", @@ -1302,14 +1196,6 @@ mod tests { } ); - assert_eq!( - TagStandard::parse(&["client", "voyage"]).unwrap(), - TagStandard::Client { - name: String::from("voyage"), - address: None - } - ); - assert_eq!( TagStandard::parse(&["dep", "nostr"]).unwrap(), TagStandard::Dependency(String::from("nostr")) @@ -1335,30 +1221,6 @@ mod tests { TagStandard::Repository(String::from("https://github.com/rust-nostr/nostr")) ); - assert_eq!( - TagStandard::parse(&["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum"]).unwrap(), - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None)) - } - ); - - assert_eq!( - TagStandard::parse(&["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", ""]).unwrap(), - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None)) - } - ); - - assert_eq!( - TagStandard::parse(&["client", "voyage", "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", "wss://relay.damus.io"]).unwrap(), - TagStandard::Client { - name: String::from("voyage"), - address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), Some(RelayUrl::parse("wss://relay.damus.io").unwrap()))) - } - ); - assert_eq!( TagStandard::parse(&[ "a", diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index b775fa3fd..383df12c4 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -54,6 +54,7 @@ pub mod nip65; pub mod nip70; pub mod nip73; pub mod nip88; +pub mod nip89; pub mod nip90; pub mod nip94; #[cfg(feature = "nip98")] diff --git a/crates/nostr/src/nips/nip89.rs b/crates/nostr/src/nips/nip89.rs new file mode 100644 index 000000000..84c755d1f --- /dev/null +++ b/crates/nostr/src/nips/nip89.rs @@ -0,0 +1,204 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-89: Recommended Application Handlers +//! +//! <https://github.com/nostr-protocol/nips/blob/master/89.md> + +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt; + +use super::nip01::Coordinate; +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::RelayUrl; + +const CLIENT: &str = "client"; + +/// NIP-89 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-89 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/89.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip89Tag { + /// `client` tag + Client { + /// Client name + name: String, + /// Client address and optional hint + address: Option<(Coordinate, Option<RelayUrl>)>, + }, +} + +impl TagCodec for Nip89Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + CLIENT => { + let (name, address) = parse_client_tag(iter)?; + Ok(Self::Client { name, address }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Client { name, address } => { + let mut tag: Vec<String> = vec![CLIENT.to_string(), name.clone()]; + + match address { + Some((coordinate, Some(hint))) => { + tag.reserve_exact(2); + tag.push(coordinate.to_string()); + tag.push(hint.to_string()); + } + Some((coordinate, None)) => { + tag.push(coordinate.to_string()); + } + _ => {} + } + + Tag::new(tag) + } + } + } +} + +impl_tag_codec_conversions!(Nip89Tag); + +#[allow(clippy::type_complexity)] +fn parse_client_tag<T, S>( + mut iter: T, +) -> Result<(String, Option<(Coordinate, Option<RelayUrl>)>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + // Possible cases: + // - ["client", "My Client"] + // - ["client", "My Client", "31990:app1-pubkey:<d-identifier>"] + // - ["client", "My Client", "31990:app1-pubkey:<d-identifier>", "wss://relay1"] + + let name: String = take_string(&mut iter, "client name")?; + + let coordinate: Option<S> = iter.next(); + + // Since the address is optional, + // don't return an error if the coordinate or relay hint parsing fails. + let address: Option<(Coordinate, Option<RelayUrl>)> = match coordinate { + // Try to parse the coordinate + Some(coordinate) => match Coordinate::parse(coordinate.as_ref()) { + // Coordinate parsing success + Ok(coordinate) => { + let relay_url: Option<S> = iter.next(); + let relay_url: Option<RelayUrl> = + relay_url.and_then(|url| RelayUrl::parse(url.as_ref()).ok()); + Some((coordinate, relay_url)) + } + // Failed to parse the coordinate + Err(..) => None, + }, + // Nothing to parse + None => None, + }; + + Ok((name, address)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_tag() { + let tag = vec!["client", "voyage"]; + let parsed = Nip89Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + Nip89Tag::Client { + name: String::from("voyage"), + address: None + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_client_tag_with_coordinate() { + let tag = vec![ + "client", + "voyage", + "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", + ]; + let parsed = Nip89Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip89Tag::Client {name: String::from("voyage"), address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None))}); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_client_tag_with_coordinate_and_relay_hint() { + let tag = vec![ + "client", + "voyage", + "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", + "wss://relay.damus.io", + ]; + let parsed = Nip89Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip89Tag::Client {name: String::from("voyage"), address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), Some(RelayUrl::parse("wss://relay.damus.io").unwrap())))}); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_client_tag_with_coordinate_and_empty_relay_hint() { + let tag = vec![ + "client", + "voyage", + "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", + "", + ]; + let parsed = Nip89Tag::parse(&tag).unwrap(); + + assert_eq!(parsed, Nip89Tag::Client {name: String::from("voyage"), address: Some((Coordinate::parse("30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum").unwrap(), None))}); + assert_eq!( + parsed.to_tag(), + Tag::parse(tag[..=2].iter().copied()).unwrap() + ); // The empty relay-hint is not serialized + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 8e8e2a30d..413154c21 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -77,6 +77,7 @@ pub use crate::nips::nip65::{self, *}; pub use crate::nips::nip70::{self, *}; pub use crate::nips::nip73::{self, *}; pub use crate::nips::nip88::{self, *}; +pub use crate::nips::nip89::{self, *}; pub use crate::nips::nip90::{self, *}; pub use crate::nips::nip94::{self, *}; #[cfg(feature = "nip98")] From f86cdd62aab1ae512df0ff82e279dee6d543e8ed Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 4 May 2026 17:11:59 +0200 Subject: [PATCH 29/40] nostr: rework NIP-23 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 10 +- crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip23.rs | 218 ++++++++++++++++++++++++++++++ crates/nostr/src/prelude.rs | 1 + 4 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 crates/nostr/src/nips/nip23.rs diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 00ec2432b..267e9d595 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -491,11 +491,11 @@ impl EventBuilder { /// let event_id = EventId::from_hex("b3e392b11f5d4f28321cedd09303a748acfd0487aea5a7450b3481c60b6e4f87").unwrap(); /// let content: &str = "Lorem [ipsum][4] dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nRead more at #[3]."; /// let tags = &[ - /// Tag::identifier("lorem-ipsum".to_string()), - /// Tag::from_standardized(TagStandard::Title("Lorem Ipsum".to_string())), - /// Tag::from_standardized(TagStandard::PublishedAt(Timestamp::from(1296962229))), - /// Tag::hashtag("placeholder".to_string()), - /// Tag::event(event_id), + /// Nip01Tag::Identifier("lorem-ipsum".to_string()).to_tag(), + /// Nip23Tag::Title("Lorem Ipsum".to_string()).to_tag(), + /// Nip23Tag::PublishedAt(Timestamp::from(1296962229)).to_tag(), + /// Nip23Tag::Hashtag("placeholder".to_string()).to_tag(), + /// Nip01Tag::Event { id: event_id, relay_hint: None, public_key: None }.to_tag(), /// ]; /// let builder = EventBuilder::long_form_text_note("My first text note from rust-nostr!"); /// ``` diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index 383df12c4..db14f0ae3 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -22,6 +22,7 @@ pub mod nip18; pub mod nip19; pub mod nip21; pub mod nip22; +pub mod nip23; pub mod nip25; pub mod nip30; pub mod nip31; diff --git a/crates/nostr/src/nips/nip23.rs b/crates/nostr/src/nips/nip23.rs new file mode 100644 index 000000000..f8cc095de --- /dev/null +++ b/crates/nostr/src/nips/nip23.rs @@ -0,0 +1,218 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-23: Long-form Content +//! +//! <https://github.com/nostr-protocol/nips/blob/master/23.md> + +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; +use core::fmt; +use core::num::ParseIntError; + +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::nips::util::{ + take_and_parse_from_str, take_and_parse_optional_from_str, take_string, take_timestamp, +}; +use crate::types::image; +use crate::types::url::{self, Url}; +use crate::{ImageDimensions, Timestamp}; + +const TITLE: &str = "title"; +const IMAGE: &str = "image"; +const SUMMARY: &str = "summary"; +const PUBLISHED_AT: &str = "published_at"; +const HASHTAG: &str = "t"; + +/// NIP-23 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Image error + Image(image::Error), + /// Parse Int error + ParseInt(ParseIntError), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Image(e) => e.fmt(f), + Self::ParseInt(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<image::Error> for Error { + fn from(e: image::Error) -> Self { + Self::Image(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(url::Error::Url(e)) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-23 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/23.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip23Tag { + /// `title` tag + Title(String), + /// `image` tag + Image(Url, Option<ImageDimensions>), + /// `summary` tag + Summary(String), + /// `published_at` tag + PublishedAt(Timestamp), + /// `t` tag + Hashtag(String), +} + +impl TagCodec for Nip23Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + TITLE => Ok(Self::Title(take_string(&mut iter, "title")?)), + IMAGE => { + let (url, dimensions) = parse_image_tag(iter)?; + Ok(Self::Image(url, dimensions)) + } + SUMMARY => Ok(Self::Summary(take_string(&mut iter, "summary")?)), + PUBLISHED_AT => { + let timestamp: Timestamp = take_timestamp::<_, _, Error>(&mut iter)?; + Ok(Self::PublishedAt(timestamp)) + } + HASHTAG => Ok(Self::Hashtag( + take_string(&mut iter, "hashtag")?.to_lowercase(), + )), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Title(title) => Tag::new(vec![String::from(TITLE), title.clone()]), + Self::Image(url, dimensions) => { + let mut tag: Vec<String> = Vec::with_capacity(2 + dimensions.is_some() as usize); + tag.push(String::from(IMAGE)); + tag.push(url.to_string()); + if let Some(dimensions) = dimensions { + tag.push(dimensions.to_string()); + } + Tag::new(tag) + } + Self::Summary(summary) => Tag::new(vec![String::from(SUMMARY), summary.clone()]), + Self::PublishedAt(timestamp) => { + Tag::new(vec![String::from(PUBLISHED_AT), timestamp.to_string()]) + } + Self::Hashtag(hashtag) => Tag::new(vec![String::from(HASHTAG), hashtag.to_lowercase()]), + } + } +} + +impl_tag_codec_conversions!(Nip23Tag); + +fn parse_image_tag<T, S>(mut iter: T) -> Result<(Url, Option<ImageDimensions>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "image URL")?; + let dimensions: Option<ImageDimensions> = take_and_parse_optional_from_str(&mut iter)?; + + Ok((url, dimensions)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_title_tag() { + let tag = vec!["title", "Lorem Ipsum"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip23Tag::Title(String::from("Lorem Ipsum"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_image_tag() { + let tag = vec!["image", "https://example.com/image.jpg", "1024x768"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip23Tag::Image( + Url::parse("https://example.com/image.jpg").unwrap(), + Some(ImageDimensions::new(1024, 768)) + ) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_summary_tag() { + let tag = vec!["summary", "Article summary"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip23Tag::Summary(String::from("Article summary"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_published_at_tag() { + let tag = vec!["published_at", "1296962229"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip23Tag::PublishedAt(Timestamp::from(1296962229))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_hashtag_tag() { + let tag = vec!["t", "Placeholder"]; + let parsed = Nip23Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip23Tag::Hashtag(String::from("placeholder"))); + assert_eq!( + parsed.to_tag(), + Tag::parse(vec!["t", "placeholder"]).unwrap() + ); + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 413154c21..e589d4a0b 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -45,6 +45,7 @@ pub use crate::nips::nip18::{self, *}; pub use crate::nips::nip19::{self, *}; pub use crate::nips::nip21::{self, *}; pub use crate::nips::nip22::{self, *}; +pub use crate::nips::nip23::{self, *}; pub use crate::nips::nip25::{self, *}; pub use crate::nips::nip30::{self, *}; pub use crate::nips::nip31::{self, *}; From 33116c7d969115e8a5694927618f168b0c9065d4 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 4 May 2026 17:21:45 +0200 Subject: [PATCH 30/40] nostr: rework NIP-7D tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 2 +- crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip7d.rs | 88 +++++++++++++++++++++++++++++++ crates/nostr/src/prelude.rs | 1 + 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 crates/nostr/src/nips/nip7d.rs diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 267e9d595..2cf75044c 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1830,7 +1830,7 @@ impl EventBuilder { { let mut builder = Self::new(Kind::Thread, content); if let Some(t) = title { - builder = builder.tag(Tag::from_standardized(TagStandard::Title(t))); + builder = builder.tag(Nip7DTag::Title(t).to_tag()); } builder } diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index db14f0ae3..1666973dc 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -54,6 +54,7 @@ pub mod nip62; pub mod nip65; pub mod nip70; pub mod nip73; +pub mod nip7d; pub mod nip88; pub mod nip89; pub mod nip90; diff --git a/crates/nostr/src/nips/nip7d.rs b/crates/nostr/src/nips/nip7d.rs new file mode 100644 index 000000000..77b46b4f7 --- /dev/null +++ b/crates/nostr/src/nips/nip7d.rs @@ -0,0 +1,88 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-7D: Threads +//! +//! <https://github.com/nostr-protocol/nips/blob/master/7D.md> + +use alloc::string::String; +use alloc::vec; +use core::fmt; + +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +const TITLE: &str = "title"; + +/// NIP-7D error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(err) => err.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(err: TagCodecError) -> Self { + Self::Codec(err) + } +} + +/// Standardized NIP-7D tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/7D.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip7DTag { + /// `title` tag + Title(String), +} + +impl TagCodec for Nip7DTag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + TITLE => Ok(Self::Title(take_string(&mut iter, "title")?)), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Title(title) => Tag::new(vec![String::from(TITLE), title.clone()]), + } + } +} + +impl_tag_codec_conversions!(Nip7DTag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_title_tag() { + let tag = vec!["title", "Lorem Ipsum"]; + let parsed = Nip7DTag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip7DTag::Title(String::from("Lorem Ipsum"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index e589d4a0b..75504d3cf 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -35,6 +35,7 @@ pub use crate::nips::nip04::{self, *}; pub use crate::nips::nip05::{self, *}; #[cfg(feature = "nip06")] pub use crate::nips::nip06::{self, *}; +pub use crate::nips::nip7d::{self, *}; pub use crate::nips::nip09::{self, *}; pub use crate::nips::nip10::{self, *}; pub use crate::nips::nip11::{self, *}; From ecd74a4b066160095857514373b391f1fdce7e4d Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Mon, 4 May 2026 17:44:19 +0200 Subject: [PATCH 31/40] nostr: rework NIP-32 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 9 ++- crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nip32.rs | 119 ++++++++++++++++++++++++++++++ crates/nostr/src/prelude.rs | 1 + 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 crates/nostr/src/nips/nip32.rs diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 2cf75044c..5b6690807 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1672,11 +1672,12 @@ impl EventBuilder { let namespace: String = namespace.into(); let label: String = label.into(); Self::new(Kind::Label, "").tags([ - Tag::from_standardized(TagStandard::LabelNamespace(namespace.clone())), - Tag::from_standardized(TagStandard::Label { + Nip32Tag::LabelNamespace(namespace.clone()).to_tag(), + Nip32Tag::Label { value: label, - namespace: Some(namespace), - }), + namespace, + } + .to_tag(), ]) } diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index 1666973dc..b5931e8a9 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -26,6 +26,7 @@ pub mod nip23; pub mod nip25; pub mod nip30; pub mod nip31; +pub mod nip32; pub mod nip34; pub mod nip35; pub mod nip38; diff --git a/crates/nostr/src/nips/nip32.rs b/crates/nostr/src/nips/nip32.rs new file mode 100644 index 000000000..a9a70e758 --- /dev/null +++ b/crates/nostr/src/nips/nip32.rs @@ -0,0 +1,119 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-32: Labeling +//! +//! <https://github.com/nostr-protocol/nips/blob/master/32.md> + +use alloc::string::String; +use alloc::vec; +use core::fmt; + +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +/// NIP-32 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-32 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/32.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip32Tag { + /// `L` tag + LabelNamespace(String), + /// `l` tag + Label { + /// Label value + value: String, + /// Label namespace + namespace: String, + }, +} + +impl TagCodec for Nip32Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + "L" => Ok(Self::LabelNamespace(take_string( + &mut iter, + "label namespace", + )?)), + "l" => { + let value: String = take_string(&mut iter, "label")?; + let namespace: String = take_string(&mut iter, "label namespace")?; + + Ok(Self::Label { value, namespace }) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::LabelNamespace(namespace) => Tag::new(vec![String::from("L"), namespace.clone()]), + Self::Label { value, namespace } => { + Tag::new(vec![String::from("l"), value.clone(), namespace.clone()]) + } + } + } +} + +impl_tag_codec_conversions!(Nip32Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_label_namespace_tag() { + let tag = vec!["L", "test"]; + let parsed = Nip32Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip32Tag::LabelNamespace(String::from("test"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_label_tag() { + let tag = vec!["l", "other", "test"]; + let parsed = Nip32Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip32Tag::Label { + value: String::from("other"), + namespace: String::from("test") + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 75504d3cf..3d7b7b235 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -50,6 +50,7 @@ pub use crate::nips::nip23::{self, *}; pub use crate::nips::nip25::{self, *}; pub use crate::nips::nip30::{self, *}; pub use crate::nips::nip31::{self, *}; +pub use crate::nips::nip32::{self, *}; pub use crate::nips::nip34::{self, *}; pub use crate::nips::nip35::{self, *}; pub use crate::nips::nip38::{self, *}; From 101ba40c14473dd0398282b9673656bbf4ccaa8d Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 5 May 2026 09:26:55 +0200 Subject: [PATCH 32/40] nostr: rework NIP-90 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 24 +- crates/nostr/src/nips/nip90.rs | 392 +++++++++++++++++++++++++++++- 2 files changed, 403 insertions(+), 13 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 5b6690807..a259c64d8 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1186,8 +1186,8 @@ impl EventBuilder { tags.extend_from_slice(&[ Tag::event(job_request.id), Tag::public_key(job_request.pubkey), - Tag::from_standardized(TagStandard::Request(job_request)), - Tag::from_standardized(TagStandard::Amount { millisats, bolt11 }), + Nip90Tag::Request(job_request).to_tag(), + Nip90Tag::Amount { millisats, bolt11 }.to_tag(), ]); Ok(Self::new(kind, payload).tags(tags)) @@ -1201,18 +1201,22 @@ impl EventBuilder { tags.push(Tag::event(data.job_request_id)); tags.push(Tag::public_key(data.customer_public_key)); - tags.push(Tag::from_standardized( - TagStandard::DataVendingMachineStatus { + tags.push( + Nip90Tag::Status { status: data.status, extra_info: data.extra_info, - }, - )); + } + .to_tag(), + ); if let Some(millisats) = data.amount_msat { - tags.push(Tag::from_standardized(TagStandard::Amount { - millisats, - bolt11: data.bolt11, - })); + tags.push( + Nip90Tag::Amount { + millisats, + bolt11: data.bolt11, + } + .to_tag(), + ); } Self::new(Kind::JobFeedback, data.payload.unwrap_or_default()).tags(tags) diff --git a/crates/nostr/src/nips/nip90.rs b/crates/nostr/src/nips/nip90.rs index b96037d39..5557a4119 100644 --- a/crates/nostr/src/nips/nip90.rs +++ b/crates/nostr/src/nips/nip90.rs @@ -6,27 +6,86 @@ //! //! <https://github.com/nostr-protocol/nips/blob/master/90.md> -use alloc::string::String; +use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; use core::fmt; +use core::num::ParseIntError; use core::str::FromStr; -use crate::{Event, EventId, PublicKey}; +use super::util::{ + take_and_parse_from_str, take_and_parse_optional_relay_url, take_optional_string, take_string, +}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url; +use crate::{Event, EventId, JsonUtil, PublicKey, RelayUrl, event}; + +const INPUT: &str = "i"; +const OUTPUT: &str = "output"; +const PARAM: &str = "param"; +const BID: &str = "bid"; +const RELAYS: &str = "relays"; +const REQUEST: &str = "request"; +const AMOUNT: &str = "amount"; +const STATUS: &str = "status"; +const ENCRYPTED: &str = "encrypted"; /// DVM Error -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum Error { + /// Event error + Event(event::Error), + /// Parse int error + ParseInt(ParseIntError), + /// Url error + Url(url::Error), + /// Codec error + Codec(TagCodecError), + /// Unknown input type + UnknownInputType, /// Unknown status UnknownStatus, } +impl core::error::Error for Error {} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Event(e) => e.fmt(f), + Self::ParseInt(e) => fmt::Display::fmt(e, f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + Self::UnknownInputType => f.write_str("Unknown input type"), Self::UnknownStatus => f.write_str("Unknown status"), } } } +impl From<event::Error> for Error { + fn from(e: event::Error) -> Self { + Self::Event(e) + } +} + +impl From<ParseIntError> for Error { + fn from(e: ParseIntError) -> Self { + Self::ParseInt(e) + } +} + +impl From<url::Error> for Error { + fn from(e: url::Error) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Data Vending Machine Status #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum DataVendingMachineStatus { @@ -76,6 +135,262 @@ impl FromStr for DataVendingMachineStatus { } } +/// Job input type +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum JobInputType { + /// URL input + Url, + /// Event input + Event, + /// Previous job output input + Job, + /// Plain text input + Text, +} + +impl fmt::Display for JobInputType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl JobInputType { + /// Get as `&str` + pub fn as_str(&self) -> &str { + match self { + Self::Url => "url", + Self::Event => "event", + Self::Job => "job", + Self::Text => "text", + } + } +} + +impl FromStr for JobInputType { + type Err = Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + "url" => Ok(Self::Url), + "event" => Ok(Self::Event), + "job" => Ok(Self::Job), + "text" => Ok(Self::Text), + _ => Err(Error::UnknownInputType), + } + } +} + +/// Standardized NIP-90 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/90.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip90Tag { + /// `i` tag + Input { + /// Input data + data: String, + /// Input type + input_type: JobInputType, + /// Relay hint for event/job inputs + relay_hint: Option<RelayUrl>, + /// Optional marker + marker: Option<String>, + }, + /// `output` tag + Output(String), + /// `param` tag + Param { + /// Parameter name + name: String, + /// Parameter value + value: String, + }, + /// `bid` tag + Bid(u64), + /// `relays` tag + Relays(Vec<RelayUrl>), + /// `request` tag + Request(Event), + /// `amount` tag + Amount { + /// Amount in millisats + millisats: u64, + /// Optional bolt11 invoice + bolt11: Option<String>, + }, + /// `status` tag + Status { + /// Job status + status: DataVendingMachineStatus, + /// Optional human-readable extra info + extra_info: Option<String>, + }, + /// `encrypted` tag + Encrypted, +} + +impl TagCodec for Nip90Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + INPUT => { + let (data, input_type, relay_hint, marker) = parse_input_tag(iter)?; + Ok(Self::Input { + data, + input_type, + relay_hint, + marker, + }) + } + OUTPUT => Ok(Self::Output(take_string(&mut iter, "output")?)), + PARAM => Ok(Nip90Tag::Param { + name: take_string(&mut iter, "param name")?, + value: take_string(&mut iter, "param value")?, + }), + BID => { + let bid: u64 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "bid")?; + Ok(Self::Bid(bid)) + } + RELAYS => Ok(Self::Relays(parse_relays_tag(iter)?)), + REQUEST => { + let request: S = iter.next().ok_or(TagCodecError::Missing("request"))?; + Ok(Self::Request(Event::from_json(request.as_ref())?)) + } + AMOUNT => { + let (millisats, bolt11) = parse_amount_tag(iter)?; + Ok(Self::Amount { millisats, bolt11 }) + } + STATUS => { + let (status, extra_info) = parse_status_tag(iter)?; + Ok(Self::Status { status, extra_info }) + } + ENCRYPTED => Ok(Self::Encrypted), + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Input { + data, + input_type, + relay_hint, + marker, + } => { + let mut tag: Vec<String> = Vec::with_capacity( + 3 + relay_hint.is_some() as usize + marker.is_some() as usize, + ); + tag.push(String::from(INPUT)); + tag.push(data.clone()); + tag.push(input_type.to_string()); + + match relay_hint { + Some(relay_hint) => tag.push(relay_hint.to_string()), + None => { + if marker.is_some() { + tag.push(String::new()); + } + } + } + + if let Some(marker) = marker { + tag.push(marker.clone()); + } + + Tag::new(tag) + } + Self::Output(output) => Tag::new(vec![String::from(OUTPUT), output.clone()]), + Self::Param { name, value } => { + Tag::new(vec![String::from(PARAM), name.clone(), value.clone()]) + } + Self::Bid(bid) => Tag::new(vec![String::from(BID), bid.to_string()]), + Self::Relays(relays) => { + let mut tag: Vec<String> = Vec::with_capacity(relays.len() + 1); + tag.push(String::from(RELAYS)); + tag.extend(relays.iter().map(ToString::to_string)); + Tag::new(tag) + } + Self::Request(event) => Tag::new(vec![String::from(REQUEST), event.as_json()]), + Self::Amount { millisats, bolt11 } => { + let mut tag: Vec<String> = vec![String::from(AMOUNT), millisats.to_string()]; + if let Some(bolt11) = bolt11 { + tag.push(bolt11.clone()); + } + Tag::new(tag) + } + Self::Status { status, extra_info } => { + let mut tag: Vec<String> = vec![String::from(STATUS), status.to_string()]; + if let Some(extra_info) = extra_info { + tag.push(extra_info.clone()); + } + Tag::new(tag) + } + Self::Encrypted => Tag::new(vec![String::from(ENCRYPTED)]), + } + } +} + +impl_tag_codec_conversions!(Nip90Tag); + +fn parse_input_tag<T, S>( + mut iter: T, +) -> Result<(String, JobInputType, Option<RelayUrl>, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let data: String = take_string(&mut iter, "input data")?; + let input_type: JobInputType = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "input type")?; + let relay_hint: Option<RelayUrl> = take_and_parse_optional_relay_url(&mut iter)?; + let marker: Option<String> = take_optional_string(&mut iter); + + Ok((data, input_type, relay_hint, marker)) +} + +fn parse_relays_tag<T, S>(iter: T) -> Result<Vec<RelayUrl>, Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let mut relays: Vec<RelayUrl> = Vec::new(); + for relay in iter { + relays.push(RelayUrl::parse(relay.as_ref())?); + } + Ok(relays) +} + +fn parse_amount_tag<T, S>(mut iter: T) -> Result<(u64, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let millisats: u64 = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "amount")?; + let bolt11: Option<String> = take_optional_string(&mut iter); + + Ok((millisats, bolt11)) +} + +fn parse_status_tag<T, S>(mut iter: T) -> Result<(DataVendingMachineStatus, Option<String>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let status: DataVendingMachineStatus = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "status")?; + let extra_info: Option<String> = take_optional_string(&mut iter); + + Ok((status, extra_info)) +} + /// Data Vending Machine (DVM) - Job Feedback data /// /// <https://github.com/nostr-protocol/nips/blob/master/90.md> @@ -132,3 +447,74 @@ impl JobFeedbackData { self } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_input_tag() { + let tag = vec!["i", "hello", "text"]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip90Tag::Input { + data: String::from("hello"), + input_type: JobInputType::Text, + relay_hint: None, + marker: None, + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_param_tag() { + let tag = vec!["param", "lang", "es"]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip90Tag::Param { + name: String::from("lang"), + value: String::from("es"), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_amount_tag() { + let tag = vec!["amount", "21000", "lnbc1..."]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip90Tag::Amount { + millisats: 21000, + bolt11: Some(String::from("lnbc1...")), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_status_tag() { + let tag = vec!["status", "processing", "working"]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip90Tag::Status { + status: DataVendingMachineStatus::Processing, + extra_info: Some(String::from("working")), + } + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_parse_encrypted_tag() { + let tag = vec!["encrypted"]; + let parsed = Nip90Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip90Tag::Encrypted); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} From f94dd9d203a0099e51b910c15d197d14a04f44bc Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 5 May 2026 09:36:30 +0200 Subject: [PATCH 33/40] nostr: fix NIP-58 kinds Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 7 ++----- crates/nostr/src/event/kind.rs | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index a259c64d8..aab41a46c 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1085,9 +1085,7 @@ impl EventBuilder { return Err(Error::NIP58(nip58::Error::InvalidKind)); } - // Add identifier `d` tag - let id_tag: Tag = Tag::identifier("profile_badges"); - let mut tags: Vec<Tag> = vec![id_tag]; + let mut tags: Vec<Tag> = Vec::new(); let badge_definitions_identifiers = badge_definitions.iter().filter_map(|event| { let id: String = event.tags.identifier()?; @@ -2129,12 +2127,11 @@ mod tests { r#"{{ "content":"", "id": "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "kind": 30008, + "kind": 10008, "pubkey": "{pub_key}", "sig":"fd0954de564cae9923c2d8ee9ab2bf35bc19757f8e328a978958a2fcc950eaba0754148a203adec29b7b64080d0cf5a32bebedd768ea6eb421a6b751bb4584a8", "created_at":1671739153, "tags":[ - ["d", "profile_badges"], ["a", "30009:{badge_one_pubkey}:bravery"], ["e", "{}"], ["a", "30009:{badge_two_pubkey}:honor"], diff --git a/crates/nostr/src/event/kind.rs b/crates/nostr/src/event/kind.rs index de3a2b8bd..687e752df 100644 --- a/crates/nostr/src/event/kind.rs +++ b/crates/nostr/src/event/kind.rs @@ -133,7 +133,8 @@ kind_variants! { NostrConnect => 24133, "Nostr Connect", "<https://github.com/nostr-protocol/nips/blob/master/47.md>", LiveEvent => 30311, "Live Event", "<https://github.com/nostr-protocol/nips/blob/master/53.md>", LiveEventMessage => 1311, "Live Event Message", "<https://github.com/nostr-protocol/nips/blob/master/53.md>", - ProfileBadges => 30008, "Profile Badges", "<https://github.com/nostr-protocol/nips/blob/master/58.md>", + ProfileBadges => 10008, "Profile Badges", "<https://github.com/nostr-protocol/nips/blob/master/58.md>", + BadgeSet => 30008, "Badge Set", "<https://github.com/nostr-protocol/nips/blob/master/58.md>", BadgeDefinition => 30009, "Badge Definition", "<https://github.com/nostr-protocol/nips/blob/master/58.md>", Seal => 13, "Seal", "<https://github.com/nostr-protocol/nips/blob/master/59.md>", GiftWrap => 1059, "Gift Wrap", "<https://github.com/nostr-protocol/nips/blob/master/59.md>", From 054f124ced8a9324dc08edd5aceceeff3eaf5e3b Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 5 May 2026 09:51:12 +0200 Subject: [PATCH 34/40] nostr: rework NIP-58 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 58 ++++---- crates/nostr/src/nips/nip58.rs | 217 ++++++++++++++++++++++++++++-- 2 files changed, 238 insertions(+), 37 deletions(-) diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index aab41a46c..40fce4ad1 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -19,6 +19,7 @@ use rand::{CryptoRng, RngCore}; use secp256k1::{Secp256k1, Signing, Verification}; use serde_json::{Value, json}; +use crate::nips::nip58::Nip58Tag; use crate::nips::nip62::VanishTarget; use crate::prelude::*; @@ -982,26 +983,24 @@ impl EventBuilder { let mut tags: Vec<Tag> = Vec::new(); // Set identifier tag - tags.push(Tag::identifier(badge_id.into())); + tags.push(Nip58Tag::Identifier(badge_id.into()).to_tag()); // Set name tag if let Some(name) = name { - tags.push(Tag::from_standardized(TagStandard::Name(name.into()))); + tags.push(Nip58Tag::Name(name.into()).to_tag()); } // Set description tag if let Some(description) = description { - tags.push(Tag::from_standardized(TagStandard::Description( - description.into(), - ))); + tags.push(Nip58Tag::Description(description.into()).to_tag()); } // Set image tag if let Some(image) = image { let image_tag = if let Some(dimensions) = image_dimensions { - Tag::from_standardized(TagStandard::Image(image, Some(dimensions))) + Nip58Tag::Image(image, Some(dimensions)).to_tag() } else { - Tag::from_standardized(TagStandard::Image(image, None)) + Nip58Tag::Image(image, None).to_tag() }; tags.push(image_tag); } @@ -1009,9 +1008,9 @@ impl EventBuilder { // Set thumbnail tags for (thumb, dimensions) in thumbnails.into_iter() { let thumb_tag = if let Some(dimensions) = dimensions { - Tag::from_standardized(TagStandard::Thumb(thumb, Some(dimensions))) + Nip58Tag::Thumb(thumb, Some(dimensions)).to_tag() } else { - Tag::from_standardized(TagStandard::Thumb(thumb, None)) + Nip58Tag::Thumb(thumb, None).to_tag() }; tags.push(thumb_tag); } @@ -1039,12 +1038,14 @@ impl EventBuilder { let mut tags = Vec::with_capacity(1); // Add identity tag - tags.push(Tag::from_standardized(TagStandard::Coordinate { - coordinate: Coordinate::new(Kind::BadgeDefinition, badge_definition.pubkey) - .identifier(badge_id), - relay_url: None, - uppercase: false, - })); + tags.push( + Nip01Tag::Coordinate { + coordinate: Coordinate::new(Kind::BadgeDefinition, badge_definition.pubkey) + .identifier(badge_id), + relay_hint: None, + } + .to_tag(), + ); // Add awarded public keys tags.extend(awarded_public_keys.into_iter().map(Tag::public_key)); @@ -1071,8 +1072,8 @@ impl EventBuilder { } for award in badge_awards.iter() { - if !award.tags.iter().any(|t| match t.standardized() { - Some(TagStandard::PublicKey { public_key, .. }) => public_key == *pubkey_awarded, + if !award.tags.iter().any(|t| match Nip01Tag::try_from(t) { + Ok(Nip01Tag::PublicKey { public_key, .. }) => public_key == *pubkey_awarded, _ => false, }) { return Err(Error::NIP58(nip58::Error::BadgeAwardsLackAwardedPublicKey)); @@ -1095,12 +1096,13 @@ impl EventBuilder { let badge_awards_identifiers = badge_awards.iter().filter_map(|event| { let (_, relay_url) = nip58::extract_awarded_public_key(event.tags.as_slice(), *pubkey_awarded)?; - let (id, a_tag) = event.tags.iter().find_map(|t| match t.standardized() { - Some(TagStandard::Coordinate { coordinate, .. }) => { - Some((coordinate.identifier, t)) - } - _ => None, - })?; + let (id, a_tag) = event + .tags + .iter() + .find_map(|t| match Nip01Tag::try_from(t) { + Ok(Nip01Tag::Coordinate { coordinate, .. }) => Some((coordinate.identifier, t)), + _ => None, + })?; Some((event, id, a_tag, relay_url)) }); @@ -1115,10 +1117,12 @@ impl EventBuilder { ((_, identifier), (badge_award_event, badge_id, a_tag, relay_url)) if badge_id == identifier => { - let badge_award_event_tag: Tag = Tag::from_standardized(TagStandard::Event { - event_id: badge_award_event.id, - relay_url: relay_url.clone(), - }); + let badge_award_event_tag: Tag = Nip01Tag::Event { + id: badge_award_event.id, + relay_hint: relay_url.clone(), + public_key: None, + } + .into(); tags.extend_from_slice(&[a_tag.clone(), badge_award_event_tag]); } _ => {} diff --git a/crates/nostr/src/nips/nip58.rs b/crates/nostr/src/nips/nip58.rs index 632abb91d..5ac94b226 100644 --- a/crates/nostr/src/nips/nip58.rs +++ b/crates/nostr/src/nips/nip58.rs @@ -2,19 +2,37 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP58: Badges +//! NIP-58: Badges //! //! <https://github.com/nostr-protocol/nips/blob/master/58.md> +use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use core::fmt; -use crate::types::RelayUrl; -use crate::{Event, Kind, PublicKey, Tag, TagStandard}; +use super::nip01::Nip01Tag; +use super::util::{take_and_parse_from_str, take_and_parse_optional_from_str, take_string}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, Url}; +use crate::types::{RelayUrl, image}; +use crate::{Event, ImageDimensions, Kind, PublicKey}; + +const IDENTIFIER: &str = "d"; +const NAME: &str = "name"; +const DESCRIPTION: &str = "description"; +const IMAGE: &str = "image"; +const THUMB: &str = "thumb"; #[derive(Debug, PartialEq, Eq)] /// Badge Award error pub enum Error { + /// Image error + Image(image::Error), + /// Url error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), /// Invalid length InvalidLength, /// Invalid kind @@ -32,15 +50,127 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Image(e) => e.fmt(f), + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidLength => f.write_str("invalid length"), Self::InvalidKind => f.write_str("invalid kind"), Self::IdentifierTagNotFound => f.write_str("identifier tag not found"), - Self::MismatchedBadgeDefinitionOrAward => f.write_str("mismatched badge definition/award"), - Self::BadgeAwardsLackAwardedPublicKey => f.write_str("badge award events lack the awarded public keybadge award events lack the awarded public key"), + Self::MismatchedBadgeDefinitionOrAward => { + f.write_str("mismatched badge definition/award") + } + Self::BadgeAwardsLackAwardedPublicKey => { + f.write_str("badge award events lack the awarded public key") + } + } + } +} + +impl From<image::Error> for Error { + fn from(e: image::Error) -> Self { + Self::Image(e) + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-58 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/58.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip58Tag { + /// `d` tag + Identifier(String), + /// `name` tag + Name(String), + /// `description` tag + Description(String), + /// `image` tag + Image(Url, Option<ImageDimensions>), + /// `thumb` tag + Thumb(Url, Option<ImageDimensions>), +} + +impl TagCodec for Nip58Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + IDENTIFIER => Ok(Self::Identifier(take_string(&mut iter, "identifier")?)), + NAME => Ok(Self::Name(take_string(&mut iter, "name")?)), + DESCRIPTION => Ok(Self::Description(take_string(&mut iter, "description")?)), + IMAGE => { + let (url, dimensions) = parse_url_and_dimensions_tag(iter, "image URL")?; + Ok(Self::Image(url, dimensions)) + } + THUMB => { + let (url, dimensions) = parse_url_and_dimensions_tag(iter, "thumbnail URL")?; + Ok(Self::Thumb(url, dimensions)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Identifier(identifier) => { + Tag::new(vec![String::from(IDENTIFIER), identifier.clone()]) + } + Self::Name(name) => Tag::new(vec![String::from(NAME), name.clone()]), + Self::Description(description) => { + Tag::new(vec![String::from(DESCRIPTION), description.clone()]) + } + Self::Image(url, dimensions) => to_url_and_dimensions_tag(IMAGE, url, dimensions), + Self::Thumb(url, dimensions) => to_url_and_dimensions_tag(THUMB, url, dimensions), } } } +impl_tag_codec_conversions!(Nip58Tag); + +fn parse_url_and_dimensions_tag<T, S>( + mut iter: T, + missing_error: &'static str, +) -> Result<(Url, Option<ImageDimensions>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let url: Url = take_and_parse_from_str::<_, _, _, Error>(&mut iter, missing_error)?; + let dimensions: Option<ImageDimensions> = take_and_parse_optional_from_str(&mut iter)?; + + Ok((url, dimensions)) +} + +fn to_url_and_dimensions_tag(kind: &str, url: &Url, dimensions: &Option<ImageDimensions>) -> Tag { + let mut tag: Vec<String> = Vec::with_capacity(2 + dimensions.is_some() as usize); + tag.push(String::from(kind)); + tag.push(url.to_string()); + + if let Some(dimensions) = dimensions { + tag.push(dimensions.to_string()); + } + + Tag::new(tag) +} + /// Helper function to filter events for a specific [`Kind`] #[inline] pub(crate) fn filter_for_kind(events: Vec<Event>, kind_needed: &Kind) -> Vec<Event> { @@ -55,12 +185,79 @@ pub(crate) fn extract_awarded_public_key( tags: &[Tag], awarded_public_key: PublicKey, ) -> Option<(PublicKey, Option<RelayUrl>)> { - tags.iter().find_map(|t| match t.standardized() { - Some(TagStandard::PublicKey { + tags.iter().find_map(|t| match Nip01Tag::try_from(t) { + Ok(Nip01Tag::PublicKey { public_key, - relay_url, - .. - }) if public_key == awarded_public_key => Some((public_key, relay_url)), + relay_hint, + }) if public_key == awarded_public_key => Some((public_key, relay_hint)), _ => None, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identifier_tag() { + let tag = vec!["d", "bravery"]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip58Tag::Identifier(String::from("bravery"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_name_tag() { + let tag = vec!["name", "Medal of Bravery"]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!(parsed, Nip58Tag::Name(String::from("Medal of Bravery"))); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_description_tag() { + let tag = vec!["description", "Awarded to users demonstrating bravery"]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip58Tag::Description(String::from("Awarded to users demonstrating bravery")) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_image_tag() { + let tag = vec![ + "image", + "https://nostr.academy/awards/bravery.png", + "1024x1024", + ]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip58Tag::Image( + Url::parse("https://nostr.academy/awards/bravery.png").unwrap(), + Some(ImageDimensions::new(1024, 1024)) + ) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } + + #[test] + fn test_thumb_tag() { + let tag = vec![ + "thumb", + "https://nostr.academy/awards/bravery_256x256.png", + "256x256", + ]; + let parsed = Nip58Tag::parse(&tag).unwrap(); + assert_eq!( + parsed, + Nip58Tag::Thumb( + Url::parse("https://nostr.academy/awards/bravery_256x256.png").unwrap(), + Some(ImageDimensions::new(256, 256)) + ) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} From ce5cbedb4dd2dc779ee1d92ae848ab94f77e3210 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 5 May 2026 10:19:50 +0200 Subject: [PATCH 35/40] nostr: rework NIP-B7 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/event/builder.rs | 7 +- crates/nostr/src/nips/mod.rs | 1 + crates/nostr/src/nips/nipb7.rs | 107 ++++++++++++++++++++++++++++++ crates/nostr/src/prelude.rs | 1 + rfs/nostr-blossom/src/bud01.rs | 6 +- 5 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 crates/nostr/src/nips/nipb7.rs diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index 40fce4ad1..dc6a7f8a8 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -1490,11 +1490,8 @@ impl EventBuilder { where I: IntoIterator<Item = Url>, { - Self::new(Kind::BlossomServerList, "").tags( - servers - .into_iter() - .map(|s| Tag::from_standardized(TagStandard::Server(s))), - ) + Self::new(Kind::BlossomServerList, "") + .tags(servers.into_iter().map(|s| NipB7Tag::Server(s).to_tag())) } /// Communities diff --git a/crates/nostr/src/nips/mod.rs b/crates/nostr/src/nips/mod.rs index b5931e8a9..e56124be2 100644 --- a/crates/nostr/src/nips/mod.rs +++ b/crates/nostr/src/nips/mod.rs @@ -63,5 +63,6 @@ pub mod nip94; #[cfg(feature = "nip98")] pub mod nip98; pub mod nipb0; +pub mod nipb7; pub mod nipc0; mod util; diff --git a/crates/nostr/src/nips/nipb7.rs b/crates/nostr/src/nips/nipb7.rs new file mode 100644 index 000000000..1f88b9544 --- /dev/null +++ b/crates/nostr/src/nips/nipb7.rs @@ -0,0 +1,107 @@ +// Copyright (c) 2022-2023 Yuki Kishimoto +// Copyright (c) 2023-2025 Rust Nostr Developers +// Distributed under the MIT software license + +//! NIP-B7: Blossom media +//! +//! <https://github.com/nostr-protocol/nips/blob/master/B7.md> + +use alloc::string::{String, ToString}; +use alloc::vec; +use core::fmt; + +use super::util::take_and_parse_from_str; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; +use crate::types::url::{self, Url}; + +const SERVER: &str = "server"; + +/// NIP-B7 error +#[derive(Debug, PartialEq)] +pub enum Error { + /// Url error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), +} + +impl core::error::Error for Error {} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), + } + } +} + +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + +/// Standardized NIP-B7 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/B7.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NipB7Tag { + /// `server` tag + Server(Url), +} + +impl TagCodec for NipB7Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + SERVER => { + let server_url: Url = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "server URL")?; + Ok(Self::Server(server_url)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Server(server_url) => { + Tag::new(vec![String::from(SERVER), server_url.to_string()]) + } + } + } +} + +impl_tag_codec_conversions!(NipB7Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_server_tag() { + let tag = vec!["server", "https://blossom.example.com/"]; + let parsed = NipB7Tag::parse(&tag).unwrap(); + + assert_eq!( + parsed, + NipB7Tag::Server(Url::parse("https://blossom.example.com").unwrap()) + ); + assert_eq!(parsed.to_tag(), Tag::parse(tag).unwrap()); + } +} diff --git a/crates/nostr/src/prelude.rs b/crates/nostr/src/prelude.rs index 3d7b7b235..8b4982415 100644 --- a/crates/nostr/src/prelude.rs +++ b/crates/nostr/src/prelude.rs @@ -86,6 +86,7 @@ pub use crate::nips::nip94::{self, *}; #[cfg(feature = "nip98")] pub use crate::nips::nip98::{self, *}; pub use crate::nips::nipb0::{self, *}; +pub use crate::nips::nipb7::{self, *}; pub use crate::nips::nipc0::{self, *}; pub use crate::parser::{self, *}; pub use crate::signer::{self, *}; diff --git a/rfs/nostr-blossom/src/bud01.rs b/rfs/nostr-blossom/src/bud01.rs index bd8e5f47b..9be3dba7f 100644 --- a/rfs/nostr-blossom/src/bud01.rs +++ b/rfs/nostr-blossom/src/bud01.rs @@ -2,8 +2,10 @@ use std::fmt; +use nostr::event::tag::TagCodec; use nostr::hashes::sha256::Hash as Sha256Hash; -use nostr::{EventBuilder, Kind, Tag, TagStandard, Timestamp, Url}; +use nostr::nips::nipb7::NipB7Tag; +use nostr::{EventBuilder, Kind, Tag, Timestamp, Url}; /// Represents the authorization data for accessing a Blossom server. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -109,7 +111,7 @@ impl From<BlossomAuthorizationScope> for Vec<Tag> { } } BlossomAuthorizationScope::ServerUrl(url) => { - tags.push(Tag::from_standardized(TagStandard::Server(url))); + tags.push(NipB7Tag::Server(url).to_tag()); } } tags From 89d088bce6baf6c906b38049bf8d1de47b03586e Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 5 May 2026 10:39:06 +0200 Subject: [PATCH 36/40] nostr: rework NIP-39 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/nips/nip39.rs | 115 ++++++++++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/crates/nostr/src/nips/nip39.rs b/crates/nostr/src/nips/nip39.rs index e97b9e8da..d70ca24e1 100644 --- a/crates/nostr/src/nips/nip39.rs +++ b/crates/nostr/src/nips/nip39.rs @@ -2,17 +2,25 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP39: External Identities in Profiles +//! NIP-39: External Identities in Profiles //! //! <https://github.com/nostr-protocol/nips/blob/master/39.md> use alloc::string::{String, ToString}; +use alloc::vec; use core::fmt; use core::str::FromStr; -/// NIP56 error +use super::util::take_string; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; + +const IDENTITY: &str = "i"; + +/// NIP-39 error #[derive(Debug, PartialEq, Eq)] pub enum Error { + /// Codec error + Codec(TagCodecError), /// Invalid identity InvalidIdentity, } @@ -22,11 +30,18 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Codec(e) => e.fmt(f), Self::InvalidIdentity => f.write_str("Invalid identity tag"), } } } +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// Supported external identity providers /// /// <https://github.com/nostr-protocol/nips/blob/master/39.md> @@ -105,7 +120,101 @@ impl Identity { } #[inline] - pub(crate) fn tag_platform_identity(&self) -> String { + fn tag_platform_identity(&self) -> String { format!("{}:{}", self.platform, self.ident) } } + +/// Standardized NIP-39 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/39.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip39Tag { + /// `i` tag + Identity(Identity), +} + +impl TagCodec for Nip39Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + IDENTITY => { + let platform_ident: String = take_string(&mut iter, "identity")?; + let proof: String = take_string(&mut iter, "proof")?; + + Ok(Self::Identity(Identity::new(platform_ident, proof)?)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::Identity(identity) => Tag::new(vec![ + String::from(IDENTITY), + identity.tag_platform_identity(), + identity.proof.clone(), + ]), + } + } +} + +impl_tag_codec_conversions!(Nip39Tag); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity_tag() { + let parsed = + Nip39Tag::parse(["i", "github:semisol", "9721ce4ee4fceb91c9711ca2a6c9a5ab"]).unwrap(); + + assert_eq!( + parsed, + Nip39Tag::Identity(Identity { + platform: ExternalIdentity::GitHub, + ident: String::from("semisol"), + proof: String::from("9721ce4ee4fceb91c9711ca2a6c9a5ab"), + }) + ); + assert_eq!( + parsed.to_tag(), + Tag::parse(["i", "github:semisol", "9721ce4ee4fceb91c9711ca2a6c9a5ab",]).unwrap() + ); + } + + #[test] + fn test_identity_tag_with_extra_values() { + let parsed = Nip39Tag::parse([ + "i", + "twitter:semisol_public", + "1619358434134196225", + "extra", + ]) + .unwrap(); + + assert_eq!( + parsed, + Nip39Tag::Identity(Identity { + platform: ExternalIdentity::Twitter, + ident: String::from("semisol_public"), + proof: String::from("1619358434134196225"), + }) + ); + } + + #[test] + fn test_identity_tag_missing_proof() { + let err = Nip39Tag::parse(["i", "github:semisol"]).unwrap_err(); + assert_eq!(err, Error::Codec(TagCodecError::Missing("proof"))); + } +} From fc1593f811db8484b03039e074952776b3d08ba0 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 5 May 2026 10:42:12 +0200 Subject: [PATCH 37/40] nostr: remove TagStandard Closes https://github.com/rust-nostr/nostr/issues/907 Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/CHANGELOG.md | 1 + crates/nostr/src/event/builder.rs | 48 +- crates/nostr/src/event/mod.rs | 2 +- crates/nostr/src/event/tag/list.rs | 51 +- crates/nostr/src/event/tag/mod.rs | 78 +- crates/nostr/src/event/tag/standard.rs | 1361 --------------------- crates/nostr/src/lib.rs | 2 +- crates/nostr/src/nips/nip35.rs | 3 +- crates/nostr/src/nips/nip38.rs | 2 +- gossip/nostr-gossip-memory/src/store.rs | 16 +- gossip/nostr-gossip-sqlite/src/store.rs | 14 +- gossip/nostr-gossip-test-suite/src/lib.rs | 7 +- 12 files changed, 70 insertions(+), 1515 deletions(-) delete mode 100644 crates/nostr/src/event/tag/standard.rs diff --git a/crates/nostr/CHANGELOG.md b/crates/nostr/CHANGELOG.md index c34b08ce2..eff937148 100644 --- a/crates/nostr/CHANGELOG.md +++ b/crates/nostr/CHANGELOG.md @@ -41,6 +41,7 @@ - Remove `EventBuilder::pow` in favor of `UnsignedEvent::mine` and `UnsignedEvent::mine_async` (https://github.com/rust-nostr/nostr/pull/1334) - Replace `kinds: Option<Vec<String>>` in `FeeSchedule` with `kinds: Option<Vec<u16>>` in `RelayInformationDocument` (https://github.com/rust-nostr/nostr/pull/1336) - Drop support for NIP-96 +- Remove TagStandard enum in favor of per-NIP tag enums (https://github.com/rust-nostr/nostr/pull/1347) ### Changed diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index dc6a7f8a8..fcd388ac7 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -530,16 +530,16 @@ impl EventBuilder { /// /// <https://github.com/nostr-protocol/nips/blob/master/03.md> #[cfg(feature = "nip03")] - pub fn opentimestamps(event_id: EventId, relay_url: Option<RelayUrl>) -> Result<Self, Error> { + pub fn opentimestamps(event_id: EventId, relay_hint: Option<RelayUrl>) -> Result<Self, Error> { let ots: String = nostr_ots::timestamp_event(&event_id.to_hex())?; - Ok( - Self::new(Kind::OpenTimestamps, ots).tags([Tag::from_standardized( - TagStandard::Event { - event_id, - relay_url, - }, - )]), - ) + Ok(Self::new(Kind::OpenTimestamps, ots).tag( + Nip01Tag::Event { + id: event_id, + relay_hint, + public_key: None, + } + .to_tag(), + )) } /// Repost @@ -811,19 +811,19 @@ impl EventBuilder { live_event_id: S, live_event_host: PublicKey, content: S, - relay_url: Option<RelayUrl>, + relay_hint: Option<RelayUrl>, ) -> Self where S: Into<String>, { - Self::new(Kind::LiveEventMessage, content).tag(Tag::from_standardized( - TagStandard::Coordinate { + Self::new(Kind::LiveEventMessage, content).tag( + Nip01Tag::Coordinate { coordinate: Coordinate::new(Kind::LiveEvent, live_event_host) .identifier(live_event_id), - relay_url, - uppercase: false, - }, - )) + relay_hint, + } + .to_tag(), + ) } /// Reporting @@ -2207,12 +2207,12 @@ mod tests { assert_eq!( repost .tags - .find_standardized(TagKind::single_letter(Alphabet::A, false)) + .find(TagKind::single_letter(Alphabet::A, false)) + .and_then(|t| Nip01Tag::try_from(t).ok()) .unwrap(), - TagStandard::Coordinate { + Nip01Tag::Coordinate { coordinate: Coordinate::new(replaceable.kind, replaceable.pubkey), - relay_url: None, - uppercase: false + relay_hint: None, } ); } @@ -2232,13 +2232,13 @@ mod tests { assert_eq!( repost .tags - .find_standardized(TagKind::single_letter(Alphabet::A, false)) + .find(TagKind::single_letter(Alphabet::A, false)) + .and_then(|t| Nip01Tag::try_from(t).ok()) .unwrap(), - TagStandard::Coordinate { + Nip01Tag::Coordinate { coordinate: Coordinate::new(addressable.kind, addressable.pubkey) .identifier("lorem"), - relay_url: None, - uppercase: false + relay_hint: None, } ); } diff --git a/crates/nostr/src/event/mod.rs b/crates/nostr/src/event/mod.rs index aca4f2cc1..556819390 100644 --- a/crates/nostr/src/event/mod.rs +++ b/crates/nostr/src/event/mod.rs @@ -28,7 +28,7 @@ pub use self::builder::EventBuilder; pub use self::error::Error; pub use self::id::EventId; pub use self::kind::Kind; -pub use self::tag::{Tag, TagKind, TagStandard, Tags}; +pub use self::tag::{Tag, TagKind, Tags}; pub use self::unsigned::UnsignedEvent; #[cfg(feature = "std")] use crate::SECP256K1; diff --git a/crates/nostr/src/event/tag/list.rs b/crates/nostr/src/event/tag/list.rs index 71b6fa904..4525dc8ef 100644 --- a/crates/nostr/src/event/tag/list.rs +++ b/crates/nostr/src/event/tag/list.rs @@ -28,7 +28,7 @@ use super::{Error, Tag}; use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip40::Nip40Tag; use crate::nips::nip42::Nip42Tag; -use crate::{EventId, PublicKey, SingleLetterTag, TagKind, TagStandard, Timestamp}; +use crate::{EventId, PublicKey, SingleLetterTag, TagKind, Timestamp}; /// Tags Indexes pub type TagsIndexes = BTreeMap<SingleLetterTag, BTreeSet<String>>; @@ -383,27 +383,12 @@ impl Tags { self.list.iter().find(|t| t.kind() == kind) } - /// Get the first tag that match [`TagKind`] and that is standardized. - #[inline] - pub fn find_standardized(&self, kind: TagKind) -> Option<TagStandard> { - self.find(kind).and_then(|t| t.standardized()) - } - /// Filter tags that match [`TagKind`]. #[inline] pub fn filter<'a>(&'a self, kind: TagKind<'a>) -> impl Iterator<Item = &'a Tag> { self.list.iter().filter(move |t| t.kind() == kind) } - /// Get the first tag that match [`TagKind`] and that is standardized. - #[inline] - pub fn filter_standardized<'a>( - &'a self, - kind: TagKind<'a>, - ) -> impl Iterator<Item = TagStandard> + 'a { - self.filter(kind).filter_map(|t| t.standardized()) - } - /// Get as slice of tags #[inline] pub fn as_slice(&self) -> &[Tag] { @@ -478,15 +463,9 @@ impl Tags { } /// Extract hashtags from `t` tags. - /// - /// This method extract only [`TagStandard::Hashtag`] variant. #[inline] - pub fn hashtags(&self) -> impl Iterator<Item = String> + '_ { - self.filter_standardized(TagKind::t()) - .filter_map(|t| match t { - TagStandard::Hashtag(hashtag) => Some(hashtag), - _ => None, - }) + pub fn hashtags(&self) -> impl Iterator<Item = &str> + '_ { + self.filter(TagKind::t()).filter_map(|t| t.content()) } fn build_indexes(&self) -> TagsIndexes { @@ -611,16 +590,16 @@ mod tests { EventId::from_hex("2be17aa3031bdcb006f0fce80c146dea9c1c0268b0af2398bb673365c6444d45") .unwrap(); - let long_p_tag_1 = Tag::from_standardized(TagStandard::PublicKey { + let long_p_tag_1 = Nip01Tag::PublicKey { public_key: pubkey1, - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - uppercase: false, - }); + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), + }; - let long_e_tag_2 = Tag::from_standardized(TagStandard::Event { - event_id: event2, - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - }); + let long_e_tag_2 = Nip01Tag::Event { + id: event2, + relay_hint: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), + public_key: None, + }; let empty_list: Vec<String> = Vec::new(); @@ -634,10 +613,10 @@ mod tests { Tag::identifier("test"), Tag::alt("testing deduplication"), Tag::alt("test"), - long_e_tag_2.clone(), + long_e_tag_2.to_tag(), Tag::event(event2), Tag::protected(), - long_p_tag_1.clone(), + long_p_tag_1.to_tag(), Tag::public_key(pubkey2), Tag::identifier("test"), ]; @@ -648,10 +627,10 @@ mod tests { let expected = vec![ Tag::protected(), Tag::custom(TagKind::p(), empty_list), // Non standard p tag - long_p_tag_1, + long_p_tag_1.to_tag(), Tag::public_key(pubkey2), Tag::event(event1), - long_e_tag_2, + long_e_tag_2.to_tag(), Tag::identifier("test"), Tag::alt("testing deduplication"), Tag::alt("test"), diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index 7107030aa..d56c038ac 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -5,6 +5,7 @@ //! Tag use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::{IntoIter, Vec}; use core::cmp::Ordering; use core::fmt; @@ -20,22 +21,19 @@ pub mod cow; mod error; pub mod kind; pub mod list; -pub mod standard; pub use self::codec::*; pub use self::cow::CowTag; pub use self::error::Error; pub use self::kind::TagKind; pub use self::list::Tags; -pub use self::standard::TagStandard; use super::id::EventId; use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip13::Nip13Tag; use crate::nips::nip31::Nip31Tag; use crate::nips::nip40::Nip40Tag; use crate::nips::nip70::Nip70Tag; -use crate::types::Url; -use crate::{ImageDimensions, PublicKey, RelayUrl, SingleLetterTag, Timestamp}; +use crate::{PublicKey, RelayUrl, SingleLetterTag, Timestamp}; /// Tag #[derive(Clone)] @@ -119,12 +117,6 @@ impl Tag { Ok(Self::new(tag)) } - /// Construct from standardized tag - #[inline] - pub fn from_standardized(standardized: TagStandard) -> Self { - Self::new(standardized.to_vec()) - } - /// Get tag kind #[inline] pub fn kind(&self) -> TagKind<'_> { @@ -148,12 +140,6 @@ impl Tag { } } - /// Attempt to parse as a standardized tag - #[inline] - pub fn standardized(&self) -> Option<TagStandard> { - TagStandard::parse(self.as_slice()).ok() - } - /// Get tag len /// /// This will never return zero. @@ -310,25 +296,6 @@ impl Tag { Nip40Tag::Expiration(timestamp).to_tag() } - /// Relay url - /// - /// JSON: `["relay", "<relay-url>"]` - #[inline] - pub fn relay(url: RelayUrl) -> Self { - Self::from_standardized(TagStandard::Relay(url)) - } - - /// Relay URLs - /// - /// JSON: `["relays", "<relay-url>", "<relay-url>"]` - #[inline] - pub fn relays<I>(urls: I) -> Self - where - I: IntoIterator<Item = RelayUrl>, - { - Self::from_standardized(TagStandard::Relays(urls.into_iter().collect())) - } - /// Compose `["t", "<hashtag>"]` tag /// /// This will convert the hashtag to lowercase. @@ -337,40 +304,7 @@ impl Tag { where T: AsRef<str>, { - Self::from_standardized(TagStandard::Hashtag(hashtag.as_ref().to_lowercase())) - } - - /// Compose `["r", "<value>"]` tag - #[inline] - pub fn reference<T>(reference: T) -> Self - where - T: Into<String>, - { - Self::from_standardized(TagStandard::Reference(reference.into())) - } - - /// Compose `["title", "<title>"]` tag - #[inline] - pub fn title<T>(title: T) -> Self - where - T: Into<String>, - { - Self::from_standardized(TagStandard::Title(title.into())) - } - - /// Compose image tag - #[inline] - pub fn image(url: Url, dimensions: Option<ImageDimensions>) -> Self { - Self::from_standardized(TagStandard::Image(url, dimensions)) - } - - /// Compose `["description", "<description>"]` tag - #[inline] - pub fn description<T>(description: T) -> Self - where - T: Into<String>, - { - Self::from_standardized(TagStandard::Description(description.into())) + Self::new(vec![String::from("t"), hashtag.as_ref().to_lowercase()]) } /// Protected event @@ -453,12 +387,6 @@ impl<'de> Deserialize<'de> for Tag { } } -impl From<TagStandard> for Tag { - fn from(standard: TagStandard) -> Self { - Self::from_standardized(standard) - } -} - #[cfg(test)] mod tests { use core::str::FromStr; diff --git a/crates/nostr/src/event/tag/standard.rs b/crates/nostr/src/event/tag/standard.rs deleted file mode 100644 index f820a80ba..000000000 --- a/crates/nostr/src/event/tag/standard.rs +++ /dev/null @@ -1,1361 +0,0 @@ -// Copyright (c) 2022-2023 Yuki Kishimoto -// Copyright (c) 2023-2025 Rust Nostr Developers -// Distributed under the MIT software license - -//! Standardized tags - -use alloc::string::{String, ToString}; -use alloc::vec::Vec; -use core::str::FromStr; - -use super::{Error, TagKind}; -use crate::event::id::EventId; -use crate::nips::nip01::Coordinate; -use crate::nips::nip39::Identity; -use crate::nips::nip48::Protocol; -use crate::nips::nip73::{ExternalContentId, Nip73Kind}; -use crate::nips::nip90::DataVendingMachineStatus; -use crate::types::{RelayUrl, Url}; -use crate::{ - Alphabet, Event, ImageDimensions, JsonUtil, Kind, PublicKey, SingleLetterTag, Timestamp, -}; - -/// Standardized tag -#[allow(deprecated)] -#[allow(missing_docs)] -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TagStandard { - /// Event - Event { - event_id: EventId, - relay_url: Option<RelayUrl>, - }, - /// Public Key - /// - /// <https://github.com/nostr-protocol/nips/blob/master/01.md> - PublicKey { - public_key: PublicKey, - relay_url: Option<RelayUrl>, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - Reference(String), - Hashtag(String), - Geohash(String), - /// External Content ID - /// - /// <https://github.com/nostr-protocol/nips/blob/master/73.md> - ExternalContent { - content: ExternalContentId, - /// Optional URL hint to redirect people to a website if the client isn't opinionated about how to interpret the id. - hint: Option<Url>, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - /// External Identity - /// - /// <https://github.com/nostr-protocol/nips/blob/master/39.md> - ExternalIdentity(Identity), - Coordinate { - coordinate: Coordinate, - relay_url: Option<RelayUrl>, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - Kind { - kind: Kind, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - Nip73Kind { - kind: Nip73Kind, - /// Whether the tag is an uppercase or not - uppercase: bool, - }, - Relay(RelayUrl), - Relays(Vec<RelayUrl>), - ContentWarning { - reason: Option<String>, - }, - Subject(String), - Challenge(String), - Title(String), - Image(Url, Option<ImageDimensions>), - Thumb(Url, Option<ImageDimensions>), - Summary(String), - Description(String), - Bolt11(String), - Preimage(String), - Amount { - millisats: u64, - bolt11: Option<String>, - }, - Name(String), - PublishedAt(Timestamp), - Url(Url), - Server(Url), - Proxy { - id: String, - protocol: Protocol, - }, - Encrypted, - Request(Event), - DataVendingMachineStatus { - status: DataVendingMachineStatus, - extra_info: Option<String>, - }, - /// Label namespace - /// - /// <https://github.com/nostr-protocol/nips/blob/master/32.md> - LabelNamespace(String), - /// Label - /// - /// <https://github.com/nostr-protocol/nips/blob/master/32.md> - Label { - /// Label value - value: String, - /// Label namespace - namespace: Option<String>, - }, - /// Required dependency - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Dependency(String), - /// File extension - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Extension(String), - /// License of the shared content - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - License(String), - /// Runtime or environment specification - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Runtime(String), - /// Reference to the origin repository - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Repository(String), - /// List of web URLs - Web(Vec<Url>), -} - -impl TagStandard { - // TODO: require the event kind for parsing a tag? - /// Parse tag from slice of string - #[inline] - pub fn parse<S>(tag: &[S]) -> Result<Self, Error> - where - S: AsRef<str>, - { - let tag_kind: TagKind = match tag.first() { - Some(kind) => TagKind::from(kind.as_ref()), - None => return Err(Error::KindNotFound), - }; - - Self::internal_parse(tag_kind, tag) - } - - fn internal_parse<S>(tag_kind: TagKind, tag: &[S]) -> Result<Self, Error> - where - S: AsRef<str>, - { - match tag_kind { - TagKind::SingleLetter(single_letter) => match single_letter { - // Parse `a` tag - SingleLetterTag { - character: Alphabet::A, - uppercase, - } => { - return parse_a_tag(tag, uppercase); - } - // Parse `e` tag - SingleLetterTag { - character: Alphabet::E, - uppercase, - } => { - return parse_e_tag(tag, uppercase); - } - // Parse `i` tag - SingleLetterTag { - character: Alphabet::I, - uppercase, - } => { - return parse_i_tag(tag, uppercase); - } - // Parse `l` tag - SingleLetterTag { - character: Alphabet::L, - uppercase, - } => { - return parse_l_tag(tag, uppercase); - } - // Parse `p` tag - SingleLetterTag { - character: Alphabet::P, - uppercase, - } => { - return parse_p_tag(tag, uppercase); - } - // Parse `r` tag - SingleLetterTag { - character: Alphabet::R, - uppercase, - } => { - return parse_r_tag(tag, uppercase); - } - // Parse `t` tag - SingleLetterTag { - character: Alphabet::T, - uppercase: false, - } => { - return parse_t_tag(tag); - } - // Parse `k` tag - SingleLetterTag { - character: Alphabet::K, - uppercase, - } => { - return parse_k_tag(tag, uppercase); - } - _ => (), // Covered later - }, - TagKind::ContentWarning => { - return Ok(Self::ContentWarning { - reason: extract_optional_string(tag, 1).map(|s| s.to_string()), - }); - } - TagKind::Encrypted => return Ok(Self::Encrypted), - TagKind::Relays => { - let urls: Vec<RelayUrl> = extract_relay_urls(tag)?; - return Ok(Self::Relays(urls)); - } - TagKind::Web => { - let urls: Vec<Url> = extract_urls(tag)?; - return Ok(Self::Web(urls)); - } - _ => (), // Covered later - }; - - let tag_len: usize = tag.len(); - - if tag_len == 2 { - let tag_1: &str = tag[1].as_ref(); - - return match tag_kind { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::G, - uppercase: false, - }) => Ok(Self::Geohash(tag_1.to_string())), - TagKind::Dependency => Ok(Self::Dependency(tag_1.to_string())), - TagKind::Relay => Ok(Self::Relay(RelayUrl::parse(tag_1)?)), - TagKind::Extension => Ok(Self::Extension(tag_1.to_string())), - TagKind::License => Ok(Self::License(tag_1.to_string())), - TagKind::Runtime => Ok(Self::Runtime(tag_1.to_string())), - TagKind::Repository => Ok(Self::Repository(tag_1.to_string())), - TagKind::Subject => Ok(Self::Subject(tag_1.to_string())), - TagKind::Challenge => Ok(Self::Challenge(tag_1.to_string())), - TagKind::Title => Ok(Self::Title(tag_1.to_string())), - TagKind::Image => Ok(Self::Image(Url::parse(tag_1)?, None)), - TagKind::Thumb => Ok(Self::Thumb(Url::parse(tag_1)?, None)), - TagKind::Server => Ok(Self::Server(Url::parse(tag_1)?)), - TagKind::Summary => Ok(Self::Summary(tag_1.to_string())), - TagKind::PublishedAt => Ok(Self::PublishedAt(Timestamp::from_str(tag_1)?)), - TagKind::Description => Ok(Self::Description(tag_1.to_string())), - TagKind::Bolt11 => Ok(Self::Bolt11(tag_1.to_string())), - TagKind::Preimage => Ok(Self::Preimage(tag_1.to_string())), - TagKind::Amount => Ok(Self::Amount { - millisats: tag_1.parse()?, - bolt11: None, - }), - TagKind::Name => Ok(Self::Name(tag_1.to_string())), - TagKind::Url => Ok(Self::Url(Url::parse(tag_1)?)), - TagKind::Status => match DataVendingMachineStatus::from_str(tag_1) { - Ok(status) => Ok(Self::DataVendingMachineStatus { - status, - extra_info: None, - }), - Err(_) => Err(Error::UnknownStandardizedTag), - }, - TagKind::Request => Ok(Self::Request(Event::from_json(tag_1)?)), - _ => Err(Error::UnknownStandardizedTag), - }; - } - - if tag_len == 3 { - let tag_1: &str = tag[1].as_ref(); - let tag_2: &str = tag[2].as_ref(); - - return match tag_kind { - TagKind::Image => Ok(Self::Image( - Url::parse(tag_1)?, - Some(ImageDimensions::from_str(tag_2)?), - )), - TagKind::Thumb => Ok(Self::Thumb( - Url::parse(tag_1)?, - Some(ImageDimensions::from_str(tag_2)?), - )), - TagKind::Proxy => Ok(Self::Proxy { - id: tag_1.to_string(), - protocol: Protocol::from(tag_2), - }), - TagKind::Status => match DataVendingMachineStatus::from_str(tag_1) { - Ok(status) => Ok(Self::DataVendingMachineStatus { - status, - extra_info: Some(tag_2.to_string()), - }), - Err(_) => Err(Error::UnknownStandardizedTag), - }, - _ => Err(Error::UnknownStandardizedTag), - }; - } - - Err(Error::UnknownStandardizedTag) - } - - /// Compose `TagStandard::Event` without `relay_url` and `marker` - /// - /// JSON: `["e", "event-id"]` - #[inline] - pub fn event(event_id: EventId) -> Self { - Self::Event { - event_id, - relay_url: None, - } - } - - /// Compose `TagStandard::PublicKey` without `relay_url` and `alias` - /// - /// JSON: `["p", "<public-key>"]` - #[inline] - pub fn public_key(public_key: PublicKey) -> Self { - Self::PublicKey { - public_key, - relay_url: None, - uppercase: false, - } - } - - /// Get tag kind - pub fn kind(&self) -> TagKind<'_> { - match self { - Self::Event { .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::E, - uppercase: false, - }), - Self::PublicKey { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::P, - uppercase: *uppercase, - }), - Self::Reference(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::R, - uppercase: false, - }), - Self::Hashtag(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::T, - uppercase: false, - }), - Self::Geohash(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::G, - uppercase: false, - }), - Self::ExternalContent { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::I, - uppercase: *uppercase, - }), - Self::ExternalIdentity(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::I, - uppercase: false, - }), - Self::Coordinate { uppercase, .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::A, - uppercase: *uppercase, - }), - Self::Kind { uppercase, .. } | Self::Nip73Kind { uppercase, .. } => { - TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::K, - uppercase: *uppercase, - }) - } - Self::Relay(..) => TagKind::Relay, - Self::ContentWarning { .. } => TagKind::ContentWarning, - Self::Subject(..) => TagKind::Subject, - Self::Challenge(..) => TagKind::Challenge, - Self::Title(..) => TagKind::Title, - Self::Image(..) => TagKind::Image, - Self::Thumb(..) => TagKind::Thumb, - Self::Summary(..) => TagKind::Summary, - Self::PublishedAt(..) => TagKind::PublishedAt, - Self::Description(..) => TagKind::Description, - Self::Bolt11(..) => TagKind::Bolt11, - Self::Preimage(..) => TagKind::Preimage, - Self::Relays(..) => TagKind::Relays, - Self::Amount { .. } => TagKind::Amount, - Self::Name(..) => TagKind::Name, - Self::Dependency(..) => TagKind::Dependency, - Self::Extension(..) => TagKind::Extension, - Self::License(..) => TagKind::License, - Self::Runtime(..) => TagKind::Runtime, - Self::Repository(..) => TagKind::Repository, - Self::Url(..) => TagKind::Url, - Self::Server(..) => TagKind::Server, - Self::DataVendingMachineStatus { .. } => TagKind::Status, - Self::Proxy { .. } => TagKind::Proxy, - Self::Encrypted => TagKind::Encrypted, - Self::Request(..) => TagKind::Request, - Self::LabelNamespace(..) => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::L, - uppercase: true, - }), - Self::Label { .. } => TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::L, - uppercase: false, - }), - Self::Web(..) => TagKind::Web, - } - } - - /// Consume tag and return string vector - #[inline] - pub fn to_vec(self) -> Vec<String> { - self.into() - } -} - -impl From<TagStandard> for Vec<String> { - fn from(standard: TagStandard) -> Self { - let tag_kind: String = standard.kind().to_string(); - let tag: Vec<String> = match standard { - TagStandard::Event { - event_id, - relay_url, - .. - } => { - // ["e", <event-id>, <relay-url>] - - let mut tag: Vec<String> = vec![tag_kind, event_id.to_hex()]; - - // Check if <relay-url> exists - if let Some(relay_url) = relay_url { - tag.push(relay_url.to_string()); - } - - tag - } - TagStandard::PublicKey { - public_key, - relay_url, - .. - } => { - let mut tag = vec![tag_kind, public_key.to_string()]; - if let Some(relay_url) = relay_url { - tag.push(relay_url.to_string()); - } - tag - } - TagStandard::Reference(r) => vec![tag_kind, r], - TagStandard::Hashtag(t) => vec![tag_kind, t], - TagStandard::Geohash(g) => vec![tag_kind, g], - TagStandard::Coordinate { - coordinate, - relay_url, - .. - } => { - let mut vec = vec![tag_kind, coordinate.to_string()]; - if let Some(relay) = relay_url { - vec.push(relay.to_string()); - } - vec - } - TagStandard::ExternalContent { content, hint, .. } => { - let mut tag = vec![tag_kind, content.to_string()]; - - if let Some(hint) = hint { - tag.push(hint.to_string()); - } - - tag - } - TagStandard::ExternalIdentity(identity) => { - vec![tag_kind, identity.tag_platform_identity(), identity.proof] - } - TagStandard::Kind { kind, .. } => vec![tag_kind, kind.to_string()], - TagStandard::Nip73Kind { kind, .. } => vec![tag_kind, kind.to_string()], - TagStandard::Relay(url) => vec![tag_kind, url.to_string()], - TagStandard::ContentWarning { reason } => { - let mut tag = vec![tag_kind]; - if let Some(reason) = reason { - tag.push(reason); - } - tag - } - TagStandard::Subject(sub) => vec![tag_kind, sub], - TagStandard::Challenge(challenge) => vec![tag_kind, challenge], - TagStandard::Title(title) => vec![tag_kind, title], - TagStandard::Image(image, dimensions) => { - let mut tag = vec![tag_kind, image.to_string()]; - if let Some(dim) = dimensions { - tag.push(dim.to_string()); - } - tag - } - TagStandard::Thumb(thumb, dimensions) => { - let mut tag = vec![tag_kind, thumb.to_string()]; - if let Some(dim) = dimensions { - tag.push(dim.to_string()); - } - tag - } - TagStandard::Summary(summary) => vec![tag_kind, summary], - TagStandard::PublishedAt(timestamp) => { - vec![tag_kind, timestamp.to_string()] - } - TagStandard::Description(description) => { - vec![tag_kind, description] - } - TagStandard::Bolt11(bolt11) => { - vec![tag_kind, bolt11] - } - TagStandard::Preimage(preimage) => { - vec![tag_kind, preimage] - } - TagStandard::Relays(relays) => vec![tag_kind] - .into_iter() - .chain(relays.iter().map(|relay| relay.to_string())) - .collect::<Vec<_>>(), - TagStandard::Amount { millisats, bolt11 } => { - let mut tag = vec![tag_kind, millisats.to_string()]; - if let Some(bolt11) = bolt11 { - tag.push(bolt11); - } - tag - } - TagStandard::Name(name) => vec![tag_kind, name], - TagStandard::Dependency(dep) => vec![tag_kind, dep], - TagStandard::Extension(ext) => vec![tag_kind, ext], - TagStandard::License(license) => vec![tag_kind, license], - TagStandard::Runtime(runtime) => vec![tag_kind, runtime], - TagStandard::Repository(repo) => vec![tag_kind, repo], - TagStandard::Url(url) => vec![tag_kind, url.to_string()], - TagStandard::Server(url) => vec![tag_kind, url.to_string()], - TagStandard::Proxy { id, protocol } => { - vec![tag_kind, id, protocol.to_string()] - } - TagStandard::Encrypted => vec![tag_kind], - TagStandard::Request(event) => vec![tag_kind, event.as_json()], - TagStandard::DataVendingMachineStatus { status, extra_info } => { - let mut tag = vec![tag_kind, status.to_string()]; - if let Some(extra_info) = extra_info { - tag.push(extra_info); - } - tag - } - TagStandard::LabelNamespace(n) => vec![tag_kind, n], - TagStandard::Label { value, namespace } => { - let mut tag: Vec<String> = vec![tag_kind, value]; - if let Some(namespace) = namespace { - tag.push(namespace); - } - tag - } - TagStandard::Web(urls) => { - let mut tag: Vec<String> = Vec::with_capacity(1 + urls.len()); - tag.push(tag_kind); - tag.extend(urls.into_iter().map(|url| url.to_string())); - tag - } - }; - - // Tag can't be empty, require at least 1 value - assert!(!tag.is_empty(), "Empty tag"); - - tag - } -} - -fn parse_a_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() >= 2 { - Ok(TagStandard::Coordinate { - coordinate: Coordinate::from_str(tag[1].as_ref())?, - relay_url: match tag.get(2).map(|u| u.as_ref()) { - Some(url) if !url.is_empty() => Some(RelayUrl::parse(url)?), - _ => None, - }, - uppercase, - }) - } else { - Err(Error::UnknownStandardizedTag) - } -} - -fn parse_e_tag<S>(tag: &[S], _uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() < 2 { - return Err(Error::UnknownStandardizedTag); - } - - let event_id: EventId = EventId::from_hex(tag[1].as_ref())?; - - // Try getting indexes 2 and make sure it is not empty. - // If this is empty, it is handled as None. - let tag_2: Option<&str> = tag.get(2).map(|r| r.as_ref()).filter(|r| !r.is_empty()); - - // Parse 2nd arg - let relay_url: Option<RelayUrl> = match tag_2 { - Some(url) => Some(RelayUrl::parse(url)?), - None => None, - }; - - Ok(TagStandard::Event { - event_id, - relay_url, - }) -} - -fn parse_i_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // External Content ID (NIP73) has min 2 values - // External Identity (NI39) has min 3 values - if tag.len() < 2 { - return Err(Error::UnknownStandardizedTag); - } - - let tag_1: &str = tag[1].as_ref(); - let tag_2: Option<&str> = tag.get(2).map(|t| t.as_ref()); - - // Check if External Identity (NIP39) - if !uppercase { - if let Some(tag_2) = tag_2 { - if let Ok(identity) = Identity::new(tag_1, tag_2) { - return Ok(TagStandard::ExternalIdentity(identity)); - } - } - } - - // Check if External Content ID (NIP73) - if let Ok(content) = ExternalContentId::from_str(tag_1) { - return Ok(TagStandard::ExternalContent { - content, - hint: match tag_2 { - Some(url) => Some(Url::parse(url)?), - None => None, - }, - uppercase, - }); - } - - Err(Error::UnknownStandardizedTag) -} - -fn parse_l_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // ["L", "<namespace>"] - if uppercase && tag.len() == 2 { - let tag_1: &str = tag[1].as_ref(); - return Ok(TagStandard::LabelNamespace(tag_1.to_string())); - } - - // ["l", "<label>"] or ["l", "<label>", "<namespace>"] - if !uppercase && tag.len() >= 2 { - let tag_1: &str = tag[1].as_ref(); - let tag_2: Option<&str> = tag.get(2).map(|t| t.as_ref()); - - return Ok(TagStandard::Label { - value: tag_1.to_string(), - namespace: tag_2.map(|n| n.to_string()), - }); - } - - Err(Error::UnknownStandardizedTag) -} - -fn parse_p_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() >= 2 { - let public_key: PublicKey = PublicKey::from_hex(tag[1].as_ref())?; - - if tag.len() >= 3 && !uppercase { - let tag_2: &str = tag[2].as_ref(); - - return if tag_2.is_empty() { - Ok(TagStandard::PublicKey { - public_key, - relay_url: None, - uppercase, - }) - } else { - Ok(TagStandard::PublicKey { - public_key, - relay_url: Some(RelayUrl::parse(tag_2)?), - uppercase, - }) - }; - } - - Ok(TagStandard::PublicKey { - public_key, - relay_url: None, - uppercase, - }) - } else { - Err(Error::UnknownStandardizedTag) - } -} - -fn parse_r_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - if tag.len() >= 2 && !uppercase { - let tag_1: &str = tag[1].as_ref(); - - return Ok(TagStandard::Reference(tag_1.to_string())); - } - - Err(Error::UnknownStandardizedTag) -} - -fn parse_t_tag<S>(tag: &[S]) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // ["t", "<hashtag>"] - - let hashtag: &str = tag.get(1).ok_or(Error::UnknownStandardizedTag)?.as_ref(); - - // Not all languages have distinct uppercase and lowercase letters. - // `char::is_uppercase` and `char::is_lowercase` will return `false` for those languages. - // So, to verify that a hashtag is invalid (non-lowercase), - // check if there is at least one uppercase char. - if hashtag.chars().any(char::is_uppercase) { - return Err(Error::UnknownStandardizedTag); - } - - Ok(TagStandard::Hashtag(hashtag.to_string())) -} - -fn parse_k_tag<S>(tag: &[S], uppercase: bool) -> Result<TagStandard, Error> -where - S: AsRef<str>, -{ - // ["k", "<kind>"] - - let kind: &str = tag.get(1).ok_or(Error::UnknownStandardizedTag)?.as_ref(); - - if let Ok(kind_number) = u16::from_str(kind) { - Ok(TagStandard::Kind { - kind: Kind::from_u16(kind_number), - uppercase, - }) - } else { - Ok(TagStandard::Nip73Kind { - kind: Nip73Kind::from_str(kind).map_err(|_| Error::UnknownStandardizedTag)?, - uppercase, - }) - } -} - -#[inline] -fn extract_optional_string<S>(tag: &[S], index: usize) -> Option<&str> -where - S: AsRef<str>, -{ - match tag.get(index).map(|t| t.as_ref()) { - Some(t) => (!t.is_empty()).then_some(t), - None => None, - } -} - -fn extract_urls<S>(tag: &[S]) -> Result<Vec<Url>, Error> -where - S: AsRef<str>, -{ - // Skip index 0 because is the tag kind - let mut list: Vec<Url> = Vec::with_capacity(tag.len().saturating_sub(1)); - for url in tag.iter().skip(1) { - list.push(Url::parse(url.as_ref())?); - } - Ok(list) -} - -fn extract_relay_urls<S>(tag: &[S]) -> Result<Vec<RelayUrl>, Error> -where - S: AsRef<str>, -{ - // Skip index 0 because is the tag kind - let mut list: Vec<RelayUrl> = Vec::with_capacity(tag.len().saturating_sub(1)); - for url in tag.iter().skip(1) { - list.push(RelayUrl::parse(url.as_ref())?); - } - Ok(list) -} - -#[cfg(test)] -mod tests { - use alloc::borrow::ToOwned; - - use super::*; - use crate::nips::nip39::ExternalIdentity; - - #[test] - fn test_nip73_kind() { - let tag = TagStandard::Nip73Kind { - kind: Nip73Kind::Url, - uppercase: true, - }; - assert_eq!(tag.to_vec(), vec!["K", "web"]); - - let tag = TagStandard::Nip73Kind { - kind: Nip73Kind::PodcastEpisode, - uppercase: false, - }; - assert_eq!(tag.to_vec(), vec!["k", "podcast:item:guid"]); - - let tag = TagStandard::Nip73Kind { - kind: Nip73Kind::BlockchainTransaction("monero".to_owned()), - uppercase: false, - }; - assert_eq!(tag.to_vec(), vec!["k", "monero:tx"]); - - let tag = TagStandard::Nip73Kind { - kind: Nip73Kind::BlockchainAddress("monero".to_owned()), - uppercase: true, - }; - assert_eq!(tag.to_vec(), vec!["K", "monero:address"]); - - assert_eq!( - TagStandard::parse(&["k", "isbn"]).unwrap(), - TagStandard::Nip73Kind { - kind: Nip73Kind::Book, - uppercase: false - } - ); - assert_eq!( - TagStandard::parse(&["K", "monero:address"]).unwrap(), - TagStandard::Nip73Kind { - kind: Nip73Kind::BlockchainAddress("monero".to_owned()), - uppercase: true - } - ); - } - - #[test] - fn test_kind() { - let tag = TagStandard::Kind { - kind: Kind::Comment, - uppercase: false, - }; - assert_eq!(tag.to_vec(), vec!["k", "1111"]); - - assert_eq!( - TagStandard::parse(&["K", "1617"]).unwrap(), - TagStandard::Kind { - kind: Kind::GitPatch, - uppercase: true - } - ); - } - - #[test] - fn test_tag_standard_serialization() { - assert_eq!( - vec!["content-warning"], - TagStandard::ContentWarning { reason: None }.to_vec() - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ], - TagStandard::public_key( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ) - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ], - TagStandard::event( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap() - ) - .to_vec() - ); - - assert_eq!( - vec!["content-warning", "reason"], - TagStandard::ContentWarning { - reason: Some(String::from("reason")) - } - .to_vec() - ); - - assert_eq!( - vec!["subject", "textnote with subject"], - TagStandard::Subject(String::from("textnote with subject")).to_vec() - ); - - assert_eq!( - vec![ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io" - ], - TagStandard::PublicKey { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - ], - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: None, - } - .to_vec() - ); - - assert_eq!( - vec![ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "wss://relay.damus.io" - ], - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - } - .to_vec() - ); - - assert_eq!( - vec!["dep", "nostr"], - TagStandard::Dependency(String::from("nostr")).to_vec() - ); - - assert_eq!( - vec!["extension", "rs"], - TagStandard::Extension(String::from("rs")).to_vec() - ); - - assert_eq!( - vec!["license", "MIT"], - TagStandard::License(String::from("MIT")).to_vec() - ); - - assert_eq!( - vec!["runtime", "rustc 1.70.0"], - TagStandard::Runtime(String::from("rustc 1.70.0")).to_vec() - ); - - assert_eq!( - vec!["repo", "https://github.com/rust-nostr/nostr"], - TagStandard::Repository(String::from("https://github.com/rust-nostr/nostr")).to_vec() - ); - - assert_eq!( - vec![ - "a", - "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum" - ], - TagStandard::Coordinate { - coordinate: Coordinate::new( - Kind::LongFormTextNote, - PublicKey::from_str( - "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919" - ) - .unwrap() - ) - .identifier("ipsum"), - relay_url: None, - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec![ - "a", - "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", - "wss://relay.nostr.org" - ], - TagStandard::Coordinate { - coordinate: Coordinate::new( - Kind::LongFormTextNote, - PublicKey::from_str( - "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919" - ) - .unwrap() - ) - .identifier("ipsum"), - relay_url: Some(RelayUrl::parse("wss://relay.nostr.org").unwrap()), - uppercase: false, - } - .to_vec() - ); - - assert_eq!( - vec!["relay", "wss://relay.damus.io"], - TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()).to_vec() - ); - - assert_eq!( - vec!["L", "#t"], - TagStandard::LabelNamespace("#t".to_string()).to_vec() - ); - - assert_eq!( - vec!["l", "IT-MI"], - TagStandard::Label { - value: "IT-MI".to_string(), - namespace: None - } - .to_vec() - ); - - assert_eq!( - vec!["l", "IT-MI", "ISO-3166-2"], - TagStandard::Label { - value: "IT-MI".to_string(), - namespace: Some("ISO-3166-2".to_string()) - } - .to_vec() - ); - - assert_eq!( - vec![ - "web", - "https://rust-nostr.org/", - "https://github.com/rust-nostr", - ], - TagStandard::Web(vec![ - Url::parse("https://rust-nostr.org").unwrap(), - Url::parse("https://github.com/rust-nostr").unwrap(), - ]) - .to_vec() - ); - } - - #[test] - fn test_tag_standard_parsing() { - assert_eq!( - TagStandard::parse(&["content-warning"]).unwrap(), - TagStandard::ContentWarning { reason: None } - ); - - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ]) - .unwrap(), - TagStandard::public_key( - PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap() - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ]) - .unwrap(), - TagStandard::event( - EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap() - ) - ); - - assert_eq!( - TagStandard::parse(&["content-warning", "reason"]).unwrap(), - TagStandard::ContentWarning { - reason: Some(String::from("reason")) - } - ); - - assert_eq!( - TagStandard::parse(&["subject", "textnote with subject"]).unwrap(), - TagStandard::Subject(String::from("textnote with subject")) - ); - - assert_eq!( - TagStandard::parse(&["r", "https://example.com"]).unwrap(), - TagStandard::Reference(String::from("https://example.com")) - ); - - assert_eq!( - TagStandard::parse(&["i", "isbn:9780765382030"]).unwrap(), - TagStandard::ExternalContent { - content: ExternalContentId::Book(String::from("9780765382030")), - hint: None, - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "i", - "podcast:guid:c90e609a-df1e-596a-bd5e-57bcc8aad6cc", - "https://podcastindex.org/podcast/c90e609a-df1e-596a-bd5e-57bcc8aad6cc" - ]) - .unwrap(), - TagStandard::ExternalContent { - content: ExternalContentId::PodcastFeed(String::from( - "c90e609a-df1e-596a-bd5e-57bcc8aad6cc" - )), - hint: Some( - Url::parse( - "https://podcastindex.org/podcast/c90e609a-df1e-596a-bd5e-57bcc8aad6cc" - ) - .unwrap() - ), - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&["i", "github:12345678", "abcdefghijklmnop"]).unwrap(), - TagStandard::ExternalIdentity(Identity { - platform: ExternalIdentity::GitHub, - ident: "12345678".to_string(), - proof: "abcdefghijklmnop".to_string() - }) - ); - - assert_eq!( - TagStandard::parse(&[ - "p", - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d", - "wss://relay.damus.io" - ]) - .unwrap(), - TagStandard::PublicKey { - public_key: PublicKey::from_str( - "13adc511de7e1cfcf1c6b7f6365fb5a03442d7bcacf565ea57fa7770912c023d" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - uppercase: false - } - ); - - assert_eq!( - TagStandard::parse(&[ - "e", - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7", - "wss://relay.damus.io" - ]) - .unwrap(), - TagStandard::Event { - event_id: EventId::from_hex( - "378f145897eea948952674269945e88612420db35791784abf0616b4fed56ef7" - ) - .unwrap(), - relay_url: Some(RelayUrl::parse("wss://relay.damus.io").unwrap()), - } - ); - - assert_eq!( - TagStandard::parse(&["dep", "nostr"]).unwrap(), - TagStandard::Dependency(String::from("nostr")) - ); - - assert_eq!( - TagStandard::parse(&["extension", "rs"]).unwrap(), - TagStandard::Extension(String::from("rs")) - ); - - assert_eq!( - TagStandard::parse(&["license", "MIT"]).unwrap(), - TagStandard::License(String::from("MIT")) - ); - - assert_eq!( - TagStandard::parse(&["runtime", "rustc 1.70.0"]).unwrap(), - TagStandard::Runtime(String::from("rustc 1.70.0")) - ); - - assert_eq!( - TagStandard::parse(&["repo", "https://github.com/rust-nostr/nostr"]).unwrap(), - TagStandard::Repository(String::from("https://github.com/rust-nostr/nostr")) - ); - - assert_eq!( - TagStandard::parse(&[ - "a", - "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", - "wss://relay.nostr.org" - ]) - .unwrap(), - TagStandard::Coordinate { - coordinate: Coordinate::new( - Kind::LongFormTextNote, - PublicKey::from_str( - "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919" - ) - .unwrap() - ) - .identifier("ipsum"), - relay_url: Some(RelayUrl::parse("wss://relay.nostr.org").unwrap()), - uppercase: false, - } - ); - - assert_eq!( - TagStandard::parse(&[ - "A", - "30023:a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919:ipsum", - "wss://relay.nostr.org" - ]) - .unwrap(), - TagStandard::Coordinate { - coordinate: Coordinate::new( - Kind::LongFormTextNote, - PublicKey::from_str( - "a695f6b60119d9521934a691347d9f78e8770b56da16bb255ee286ddf9fda919" - ) - .unwrap() - ) - .identifier("ipsum"), - relay_url: Some(RelayUrl::parse("wss://relay.nostr.org").unwrap()), - uppercase: true, - } - ); - - assert_eq!( - TagStandard::parse(&["relay", "wss://relay.damus.io"]).unwrap(), - TagStandard::Relay(RelayUrl::parse("wss://relay.damus.io").unwrap()) - ); - - assert_eq!( - TagStandard::parse(&[ - "relays", - "wss://relay.damus.io/", - "wss://nostr-relay.wlvs.space/", - "wss://nostr.fmt.wiz.biz/" - ]) - .unwrap(), - TagStandard::Relays(vec![ - RelayUrl::parse("wss://relay.damus.io/").unwrap(), - RelayUrl::parse("wss://nostr-relay.wlvs.space/").unwrap(), - RelayUrl::parse("wss://nostr.fmt.wiz.biz").unwrap(), - ]) - ); - - assert_eq!( - TagStandard::parse(&[ - "bolt11", - "lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0"]).unwrap(), - TagStandard::Bolt11("lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0".to_string()) - ); - - assert_eq!( - TagStandard::parse(&[ - "preimage", - "5d006d2cf1e73c7148e7519a4c68adc81642ce0e25a432b2434c99f97344c15f" - ]) - .unwrap(), - TagStandard::Preimage( - "5d006d2cf1e73c7148e7519a4c68adc81642ce0e25a432b2434c99f97344c15f".to_string() - ) - ); - - assert_eq!( - TagStandard::parse(&[ - "description", - "{\"pubkey\":\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\",\"content\":\"\",\"id\":\"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d\",\"created_at\":1674164539,\"sig\":\"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d\",\"kind\":9734,\"tags\":[[\"e\",\"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"],[\"relays\",\"wss://relay.damus.io\",\"wss://nostr-relay.wlvs.space\",\"wss://nostr.fmt.wiz.biz\",\"wss://relay.nostr.bg\",\"wss://nostr.oxtr.dev\",\"wss://nostr.v0l.io\",\"wss://brb.io\",\"wss://nostr.bitcoiner.social\",\"ws://monad.jb55.com:8080\",\"wss://relay.snort.social\"]]}" - ]).unwrap(), - TagStandard::Description("{\"pubkey\":\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\",\"content\":\"\",\"id\":\"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d\",\"created_at\":1674164539,\"sig\":\"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d\",\"kind\":9734,\"tags\":[[\"e\",\"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"],[\"relays\",\"wss://relay.damus.io\",\"wss://nostr-relay.wlvs.space\",\"wss://nostr.fmt.wiz.biz\",\"wss://relay.nostr.bg\",\"wss://nostr.oxtr.dev\",\"wss://nostr.v0l.io\",\"wss://brb.io\",\"wss://nostr.bitcoiner.social\",\"ws://monad.jb55.com:8080\",\"wss://relay.snort.social\"]]}".to_string()) - ); - - assert_eq!( - TagStandard::parse(&["amount", "10000"]).unwrap(), - TagStandard::Amount { - millisats: 10_000, - bolt11: None - } - ); - - assert_eq!( - TagStandard::parse(&["L", "#t"]).unwrap(), - TagStandard::LabelNamespace("#t".to_string()) - ); - - assert_eq!( - TagStandard::parse(&["l", "IT-MI"]).unwrap(), - TagStandard::Label { - value: "IT-MI".to_string(), - namespace: None - } - ); - - assert_eq!( - TagStandard::parse(&["l", "IT-MI", "ISO-3166-2"]).unwrap(), - TagStandard::Label { - value: "IT-MI".to_string(), - namespace: Some("ISO-3166-2".to_string()) - } - ); - - assert_eq!( - TagStandard::parse(&[ - "web", - "https://rust-nostr.org/", - "https://github.com/rust-nostr", - ]) - .unwrap(), - TagStandard::Web(vec![ - Url::parse("https://rust-nostr.org").unwrap(), - Url::parse("https://github.com/rust-nostr").unwrap(), - ]) - ); - - assert_eq!( - TagStandard::parse(&["t", "Nostr"]), - Err(Error::UnknownStandardizedTag) - ); - assert!(TagStandard::parse(&["t", "nostr"]).is_ok()); - assert!(TagStandard::parse(&["t", "سلام"]).is_ok()); - } -} diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs index bec23da9d..fc7bb15a3 100644 --- a/crates/nostr/src/lib.rs +++ b/crates/nostr/src/lib.rs @@ -53,7 +53,7 @@ pub mod types; pub mod util; #[doc(hidden)] -pub use self::event::tag::{Tag, TagKind, TagStandard, Tags}; +pub use self::event::tag::{Tag, TagKind, Tags}; #[doc(hidden)] pub use self::event::{Event, EventBuilder, EventId, Kind, UnsignedEvent}; #[doc(hidden)] diff --git a/crates/nostr/src/nips/nip35.rs b/crates/nostr/src/nips/nip35.rs index a07738b43..087761490 100644 --- a/crates/nostr/src/nips/nip35.rs +++ b/crates/nostr/src/nips/nip35.rs @@ -9,6 +9,7 @@ //! <https://github.com/nostr-protocol/nips/blob/master/35.md> use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use hashes::sha1::Hash as Sha1Hash; @@ -54,7 +55,7 @@ impl Torrent { + self.hashtags.len(), ); - tags.push(Tag::title(self.title)); + tags.push(Tag::new(vec![String::from("title"), self.title])); tags.push(Tag::custom(TagKind::x(), [self.info_hash.to_string()])); diff --git a/crates/nostr/src/nips/nip38.rs b/crates/nostr/src/nips/nip38.rs index b27507e34..4c312cafe 100644 --- a/crates/nostr/src/nips/nip38.rs +++ b/crates/nostr/src/nips/nip38.rs @@ -82,7 +82,7 @@ impl From<LiveStatus> for Vec<Tag> { } if let Some(content) = reference { - tags.push(Tag::reference(content)); + tags.push(Tag::new(vec![String::from("r"), content])); } tags diff --git a/gossip/nostr-gossip-memory/src/store.rs b/gossip/nostr-gossip-memory/src/store.rs index b9b384436..518fd1af0 100644 --- a/gossip/nostr-gossip-memory/src/store.rs +++ b/gossip/nostr-gossip-memory/src/store.rs @@ -6,10 +6,11 @@ use std::num::NonZeroUsize; use indexmap::IndexMap; use lru::LruCache; +use nostr::nips::nip01::Nip01Tag; use nostr::nips::nip17; use nostr::nips::nip65::{self, RelayMetadata}; use nostr::util::BoxedFuture; -use nostr::{Event, Kind, PublicKey, RelayUrl, TagKind, TagStandard, Timestamp}; +use nostr::{Event, Kind, PublicKey, RelayUrl, TagKind, Timestamp}; use nostr_gossip::error::GossipError; use nostr_gossip::flags::GossipFlags; use nostr_gossip::{ @@ -125,16 +126,19 @@ impl NostrGossipMemory { } // Extract hints _ => { - for tag in event.tags.filter_standardized(TagKind::p()) { - if let TagStandard::PublicKey { + for tag in event + .tags + .filter(TagKind::p()) + .filter_map(|t| Nip01Tag::try_from(t).ok()) + { + if let Nip01Tag::PublicKey { public_key, - relay_url: Some(relay_url), - .. + relay_hint: Some(relay_hint), } = tag { let pk_data: &mut PkData = public_keys.get_or_insert_mut(public_key, PkData::default); - update_relay_per_user(pk_data, relay_url.clone(), GossipFlags::HINT); + update_relay_per_user(pk_data, relay_hint, GossipFlags::HINT); } } } diff --git a/gossip/nostr-gossip-sqlite/src/store.rs b/gossip/nostr-gossip-sqlite/src/store.rs index 533b2433c..3930bbc3c 100644 --- a/gossip/nostr-gossip-sqlite/src/store.rs +++ b/gossip/nostr-gossip-sqlite/src/store.rs @@ -5,10 +5,11 @@ use std::collections::{BTreeSet, HashSet}; use std::num::NonZeroUsize; use std::path::Path; +use nostr::nips::nip01::Nip01Tag; use nostr::nips::nip17; use nostr::nips::nip65::{self, RelayMetadata}; use nostr::util::BoxedFuture; -use nostr::{Event, Kind, PublicKey, RelayUrl, TagKind, TagStandard, Timestamp}; +use nostr::{Event, Kind, PublicKey, RelayUrl, TagKind, Timestamp}; use nostr_gossip::error::GossipError; use nostr_gossip::flags::GossipFlags; use nostr_gossip::{ @@ -556,11 +557,14 @@ where } fn update_hints(tx: &Transaction<'_>, event: &Event) -> Result<(), Error> { - for tag in event.tags.filter_standardized(TagKind::p()) { - if let TagStandard::PublicKey { + for tag in event + .tags + .filter(TagKind::p()) + .filter_map(|t| Nip01Tag::try_from(t).ok()) + { + if let Nip01Tag::PublicKey { public_key, - relay_url: Some(relay_url), - .. + relay_hint: Some(relay_url), } = tag { let p_tag_pk_id: i32 = get_or_save_public_key(tx, &public_key)?; diff --git a/gossip/nostr-gossip-test-suite/src/lib.rs b/gossip/nostr-gossip-test-suite/src/lib.rs index b352e3340..63f460a3d 100644 --- a/gossip/nostr-gossip-test-suite/src/lib.rs +++ b/gossip/nostr-gossip-test-suite/src/lib.rs @@ -95,11 +95,10 @@ macro_rules! gossip_unit_tests { let keys = Keys::generate(); let event = EventBuilder::text_note("test") - .tag(Tag::from_standardized( - TagStandard::PublicKey { + .tag(Tag::from( + Nip01Tag::PublicKey { public_key, - relay_url: Some(relay_url.clone()), - uppercase: false, + relay_hint: Some(relay_url.clone()), }, )) .sign_with_keys(&keys) From 1b5b793ca8d0d330e9dda6148456f063f1e50ebe Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 5 May 2026 11:28:23 +0200 Subject: [PATCH 38/40] nostr: remove TagKind Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/CHANGELOG.md | 1 + crates/nostr/src/event/builder.rs | 68 +-- crates/nostr/src/event/mod.rs | 2 +- crates/nostr/src/event/tag/kind.rs | 464 ------------------- crates/nostr/src/event/tag/list.rs | 58 +-- crates/nostr/src/event/tag/mod.rs | 41 +- crates/nostr/src/lib.rs | 2 +- crates/nostr/src/nips/nip13/mod.rs | 6 +- crates/nostr/src/nips/nip13/single_thread.rs | 2 +- crates/nostr/src/nips/nip35.rs | 13 +- crates/nostr/src/nips/nip53.rs | 4 +- crates/nostr/src/nips/nip60.rs | 11 +- gossip/nostr-gossip-memory/src/store.rs | 14 +- gossip/nostr-gossip-sqlite/src/store.rs | 14 +- 14 files changed, 96 insertions(+), 604 deletions(-) delete mode 100644 crates/nostr/src/event/tag/kind.rs diff --git a/crates/nostr/CHANGELOG.md b/crates/nostr/CHANGELOG.md index eff937148..1bc930180 100644 --- a/crates/nostr/CHANGELOG.md +++ b/crates/nostr/CHANGELOG.md @@ -42,6 +42,7 @@ - Replace `kinds: Option<Vec<String>>` in `FeeSchedule` with `kinds: Option<Vec<u16>>` in `RelayInformationDocument` (https://github.com/rust-nostr/nostr/pull/1336) - Drop support for NIP-96 - Remove TagStandard enum in favor of per-NIP tag enums (https://github.com/rust-nostr/nostr/pull/1347) +- Remove TagKind (https://github.com/rust-nostr/nostr/pull/1347) ### Changed diff --git a/crates/nostr/src/event/builder.rs b/crates/nostr/src/event/builder.rs index fcd388ac7..b62e5eb2e 100644 --- a/crates/nostr/src/event/builder.rs +++ b/crates/nostr/src/event/builder.rs @@ -255,7 +255,7 @@ impl EventBuilder { if !self.allow_self_tagging { let public_key_hex: String = public_key.to_hex(); self.tags.retain(|t| { - if t.kind() == TagKind::p() { + if t.kind() == "p" { if let Some(content) = t.content() { if content == public_key_hex { return false; // Remove `p` tag that match author @@ -895,50 +895,17 @@ impl EventBuilder { } // add e tag - if let Some(tag) = zap_request - .tags - .iter() - .find(|t| { - t.kind() - == TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::E, - uppercase: false, - }) - }) - .cloned() - { + if let Some(tag) = zap_request.tags.iter().find(|t| t.kind() == "e").cloned() { tags.push(tag); } // add a tag - if let Some(tag) = zap_request - .tags - .iter() - .find(|t| { - t.kind() - == TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::A, - uppercase: false, - }) - }) - .cloned() - { + if let Some(tag) = zap_request.tags.iter().find(|t| t.kind() == "a").cloned() { tags.push(tag); } // add p tag - if let Some(tag) = zap_request - .tags - .iter() - .find(|t| { - t.kind() - == TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::P, - uppercase: false, - }) - }) - .cloned() - { + if let Some(tag) = zap_request.tags.iter().find(|t| t.kind() == "p").cloned() { tags.push(tag); } @@ -1172,12 +1139,7 @@ impl EventBuilder { .tags .iter() .filter_map(|t| { - if t.kind() - == TagKind::SingleLetter(SingleLetterTag { - character: Alphabet::I, - uppercase: false, - }) - { + if t.kind() == "i" { Some(t.clone()) } else { None @@ -1956,7 +1918,7 @@ mod tests { .tags .clone() .iter() - .any(|t| t.kind() == TagKind::Preimage); + .any(|t| t.kind() == "preimage"); assert!(has_preimage_tag); } @@ -1977,7 +1939,7 @@ mod tests { .tags .clone() .iter() - .any(|t| t.kind() == TagKind::Preimage); + .any(|t| t.kind() == "preimage"); assert!(!has_preimage_tag); } @@ -1988,10 +1950,7 @@ mod tests { let event_builder = EventBuilder::define_badge(badge_id, None, None, None, None, Vec::new()); - let has_id = - event_builder.tags.clone().iter().any(|t| { - t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)) - }); + let has_id = event_builder.tags.clone().iter().any(|t| t.kind() == "d"); assert!(has_id); assert_eq!(Kind::BadgeDefinition, event_builder.kind); @@ -2012,10 +1971,7 @@ mod tests { let event_builder = EventBuilder::define_badge(badge_id, name, description, image_url, image_size, thumbs); - let has_id = - event_builder.tags.clone().iter().any(|t| { - t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)) - }); + let has_id = event_builder.tags.clone().iter().any(|t| t.kind() == "d"); assert!(has_id); assert_eq!(Kind::BadgeDefinition, event_builder.kind); @@ -2207,7 +2163,8 @@ mod tests { assert_eq!( repost .tags - .find(TagKind::single_letter(Alphabet::A, false)) + .iter() + .find(|t| t.kind() == "a") .and_then(|t| Nip01Tag::try_from(t).ok()) .unwrap(), Nip01Tag::Coordinate { @@ -2232,7 +2189,8 @@ mod tests { assert_eq!( repost .tags - .find(TagKind::single_letter(Alphabet::A, false)) + .iter() + .find(|t| t.kind() == "a") .and_then(|t| Nip01Tag::try_from(t).ok()) .unwrap(), Nip01Tag::Coordinate { diff --git a/crates/nostr/src/event/mod.rs b/crates/nostr/src/event/mod.rs index 556819390..e62813f60 100644 --- a/crates/nostr/src/event/mod.rs +++ b/crates/nostr/src/event/mod.rs @@ -28,7 +28,7 @@ pub use self::builder::EventBuilder; pub use self::error::Error; pub use self::id::EventId; pub use self::kind::Kind; -pub use self::tag::{Tag, TagKind, Tags}; +pub use self::tag::{Tag, Tags}; pub use self::unsigned::UnsignedEvent; #[cfg(feature = "std")] use crate::SECP256K1; diff --git a/crates/nostr/src/event/tag/kind.rs b/crates/nostr/src/event/tag/kind.rs deleted file mode 100644 index 4c0222925..000000000 --- a/crates/nostr/src/event/tag/kind.rs +++ /dev/null @@ -1,464 +0,0 @@ -// Copyright (c) 2022-2023 Yuki Kishimoto -// Copyright (c) 2023-2025 Rust Nostr Developers -// Distributed under the MIT software license - -//! Tag kind - -use alloc::borrow::Cow; -use core::cmp::Ordering; -use core::fmt; -use core::hash::{Hash, Hasher}; -use core::str::FromStr; - -use crate::{Alphabet, SingleLetterTag}; - -/// Tag kind -#[derive(Debug, Clone)] -pub enum TagKind<'a> { - /// Human-readable plaintext summary of what that event is about - /// - /// <https://github.com/nostr-protocol/nips/blob/master/31.md> - Alt, - /// Amount - Amount, - /// Bolt11 invoice - Bolt11, - /// Challenge - Challenge, - /// Clone - Clone, - /// Commit - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - Commit, - /// HEAD - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - Head, - /// Branch name - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - BranchName, - /// Merge base - /// - /// <https://github.com/nostr-protocol/nips/blob/master/34.md> - MergeBase, - /// Content warning - ContentWarning, - /// Required dependency - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Dependency, - /// Description - Description, - /// Encrypted - Encrypted, - /// Expiration - /// - /// <https://github.com/nostr-protocol/nips/blob/master/40.md> - Expiration, - /// File extension - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Extension, - /// File - File, - /// Image - Image, - /// License of the shared content - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - License, - /// Maintainers - Maintainers, - /// MLS Protocol Version - MlsProtocolVersion, - /// MLS Cipher Suite - MlsCiphersuite, - /// MLS Extensions - MlsExtensions, - /// Name - Name, - /// Nonce - /// - /// <https://github.com/nostr-protocol/nips/blob/master/13.md> - Nonce, - /// Preimage - Preimage, - /// Proxy - Proxy, - /// PublishedAt - PublishedAt, - /// Relay - Relay, - /// Relays - Relays, - /// Reference to the origin repository - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Repository, - /// Request - Request, - /// Runtime or environment specification - /// - /// <https://github.com/nostr-protocol/nips/blob/master/C0.md> - Runtime, - /// Server - Server, - /// Status - Status, - /// Subject - Subject, - /// Summary - Summary, - /// Title - Title, - /// Thumbnail - Thumb, - /// Tracker - Tracker, - /// Url - Url, - /// Web - Web, - /// Single letter - SingleLetter(SingleLetterTag), - /// Custom - Custom(Cow<'a, str>), -} - -impl PartialEq for TagKind<'_> { - fn eq(&self, other: &TagKind<'_>) -> bool { - self.as_str() == other.as_str() - } -} - -impl Eq for TagKind<'_> {} - -impl PartialOrd for TagKind<'_> { - fn partial_cmp(&self, other: &Self) -> Option<Ordering> { - Some(self.cmp(other)) - } -} - -impl Ord for TagKind<'_> { - fn cmp(&self, other: &Self) -> Ordering { - self.as_str().cmp(other.as_str()) - } -} - -impl Hash for TagKind<'_> { - fn hash<H>(&self, state: &mut H) - where - H: Hasher, - { - self.as_str().hash(state); - } -} - -impl<'a> TagKind<'a> { - /// Construct a single letter tag - #[inline] - pub const fn single_letter(character: Alphabet, uppercase: bool) -> Self { - Self::SingleLetter(SingleLetterTag { - character, - uppercase, - }) - } - - /// Construct `a` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A))`. - #[inline] - pub const fn a() -> Self { - Self::single_letter(Alphabet::A, false) - } - - /// Construct `d` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D))`. - #[inline] - pub const fn d() -> Self { - Self::single_letter(Alphabet::D, false) - } - - /// Construct `e` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E))`. - #[inline] - pub const fn e() -> Self { - Self::single_letter(Alphabet::E, false) - } - - /// Construct `h` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::H))`. - #[inline] - pub const fn h() -> Self { - Self::single_letter(Alphabet::H, false) - } - - /// Construct `i` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::I))`. - #[inline] - pub const fn i() -> Self { - Self::single_letter(Alphabet::I, false) - } - - /// Construct `k` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K))`. - #[inline] - pub const fn k() -> Self { - Self::single_letter(Alphabet::K, false) - } - - /// Construct `p` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P))`. - #[inline] - pub const fn p() -> Self { - Self::single_letter(Alphabet::P, false) - } - - /// Construct `t` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::T))`. - #[inline] - pub const fn t() -> Self { - Self::single_letter(Alphabet::T, false) - } - - /// Construct `u` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::U))`. - #[inline] - pub const fn u() -> Self { - Self::single_letter(Alphabet::U, false) - } - - /// Construct `q` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Q))`. - #[inline] - pub const fn q() -> Self { - Self::single_letter(Alphabet::Q, false) - } - - /// Construct `x` kind - /// - /// Shorthand for `TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::X))`. - #[inline] - pub const fn x() -> Self { - Self::single_letter(Alphabet::X, false) - } - - /// Construct [`TagKind::Custom`] - /// - /// Shorthand for `TagKind::Custom(Cow::from(...))`. - #[inline] - pub fn custom<T>(kind: T) -> Self - where - T: Into<Cow<'a, str>>, - { - Self::Custom(kind.into()) - } - - /// Convert to `&str` - pub fn as_str(&self) -> &str { - match self { - Self::Alt => "alt", - Self::Amount => "amount", - Self::Bolt11 => "bolt11", - Self::BranchName => "branch-name", - Self::Challenge => "challenge", - Self::Clone => "clone", - Self::Commit => "commit", - Self::ContentWarning => "content-warning", - Self::Dependency => "dep", - Self::Description => "description", - Self::Encrypted => "encrypted", - Self::Expiration => "expiration", - Self::Extension => "extension", - Self::File => "file", - Self::Head => "HEAD", - Self::Image => "image", - Self::License => "license", - Self::Maintainers => "maintainers", - Self::MergeBase => "merge-base", - Self::MlsProtocolVersion => "mls_protocol_version", - Self::MlsCiphersuite => "mls_ciphersuite", - Self::MlsExtensions => "mls_extensions", - Self::Name => "name", - Self::Nonce => "nonce", - Self::Preimage => "preimage", - Self::Proxy => "proxy", - Self::PublishedAt => "published_at", - Self::Relay => "relay", - Self::Relays => "relays", - Self::Repository => "repo", - Self::Request => "request", - Self::Runtime => "runtime", - Self::Server => "server", - Self::Status => "status", - Self::Subject => "subject", - Self::Summary => "summary", - Self::Title => "title", - Self::Thumb => "thumb", - Self::Tracker => "tracker", - Self::Url => "url", - Self::Web => "web", - Self::SingleLetter(s) => s.as_str(), - Self::Custom(tag) => tag.as_ref(), - } - } -} - -impl fmt::Display for TagKind<'_> { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -impl<'a> From<&'a str> for TagKind<'a> { - fn from(kind: &'a str) -> Self { - match kind { - "alt" => Self::Alt, - "amount" => Self::Amount, - "bolt11" => Self::Bolt11, - "branch-name" => Self::BranchName, - "challenge" => Self::Challenge, - "clone" => Self::Clone, - "commit" => Self::Commit, - "content-warning" => Self::ContentWarning, - "dep" => Self::Dependency, - "description" => Self::Description, - "encrypted" => Self::Encrypted, - "expiration" => Self::Expiration, - "extension" => Self::Extension, - "file" => Self::File, - "image" => Self::Image, - "license" => Self::License, - "maintainers" => Self::Maintainers, - "merge-base" => Self::MergeBase, - "mls_protocol_version" => Self::MlsProtocolVersion, - "mls_ciphersuite" => Self::MlsCiphersuite, - "mls_extensions" => Self::MlsExtensions, - "name" => Self::Name, - "nonce" => Self::Nonce, - "preimage" => Self::Preimage, - "proxy" => Self::Proxy, - "published_at" => Self::PublishedAt, - "relay" => Self::Relay, - "relays" => Self::Relays, - "repo" => Self::Repository, - "request" => Self::Request, - "runtime" => Self::Runtime, - "HEAD" => Self::Head, - "server" => Self::Server, - "status" => Self::Status, - "subject" => Self::Subject, - "summary" => Self::Summary, - "title" => Self::Title, - "thumb" => Self::Thumb, - "tracker" => Self::Tracker, - "url" => Self::Url, - "web" => Self::Web, - k => match SingleLetterTag::from_str(k) { - Ok(s) => Self::SingleLetter(s), - Err(..) => Self::Custom(Cow::Borrowed(k)), - }, - } - } -} - -#[cfg(test)] -mod tests { - use alloc::string::String; - - use super::*; - - #[test] - fn test_custom_tag_kind_constructor() { - let owned = TagKind::custom(String::from("owned")); - match owned { - TagKind::Custom(Cow::Owned(val)) => assert_eq!(val, "owned"), - _ => panic!("Unexpected tag kind"), - }; - - let borrowed = TagKind::custom("borrowed"); - match borrowed { - TagKind::Custom(Cow::Borrowed(val)) => assert_eq!(val, "borrowed"), - _ => panic!("Unexpected tag kind"), - }; - } - - #[test] - fn test_de_serialization() { - assert_eq!(TagKind::from("alt"), TagKind::Alt); - assert_eq!(TagKind::Alt.as_str(), "alt"); - - assert_eq!(TagKind::from("amount"), TagKind::Amount); - assert_eq!(TagKind::Amount.as_str(), "amount"); - - assert_eq!(TagKind::from("clone"), TagKind::Clone); - assert_eq!(TagKind::Clone.as_str(), "clone"); - - assert_eq!(TagKind::from("dep"), TagKind::Dependency); - assert_eq!(TagKind::Dependency.as_str(), "dep"); - - assert_eq!(TagKind::from("expiration"), TagKind::Expiration); - assert_eq!(TagKind::Expiration.as_str(), "expiration"); - - assert_eq!(TagKind::from("extension"), TagKind::Extension); - assert_eq!(TagKind::Extension.as_str(), "extension"); - - assert_eq!(TagKind::from("file"), TagKind::File); - assert_eq!(TagKind::File.as_str(), "file"); - - assert_eq!(TagKind::from("HEAD"), TagKind::Head); - assert_eq!(TagKind::Head.as_str(), "HEAD"); - - assert_eq!(TagKind::from("license"), TagKind::License); - assert_eq!(TagKind::License.as_str(), "license"); - - assert_eq!(TagKind::from("maintainers"), TagKind::Maintainers); - assert_eq!(TagKind::Maintainers.as_str(), "maintainers"); - - assert_eq!(TagKind::from("repo"), TagKind::Repository); - assert_eq!(TagKind::Repository.as_str(), "repo"); - - assert_eq!(TagKind::from("runtime"), TagKind::Runtime); - assert_eq!(TagKind::Runtime.as_str(), "runtime"); - - assert_eq!(TagKind::from("tracker"), TagKind::Tracker); - assert_eq!(TagKind::Tracker.as_str(), "tracker"); - - assert_eq!(TagKind::from("web"), TagKind::Web); - assert_eq!(TagKind::Web.as_str(), "web"); - - assert_eq!(TagKind::from("a"), TagKind::a()); - assert_eq!(TagKind::a().as_str(), "a"); - - assert_eq!(TagKind::from("e"), TagKind::e()); - assert_eq!(TagKind::e().as_str(), "e"); - - assert_eq!(TagKind::from("p"), TagKind::p()); - assert_eq!(TagKind::p().as_str(), "p"); - } - - #[test] - fn test_eq() { - assert_eq!(TagKind::Custom(Cow::from("p")), TagKind::p()); - assert_eq!( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)), - TagKind::p() - ); - assert_eq!(TagKind::Custom(Cow::from("e")), TagKind::e()); - assert_eq!( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::E)), - TagKind::e() - ); - } -} diff --git a/crates/nostr/src/event/tag/list.rs b/crates/nostr/src/event/tag/list.rs index 4525dc8ef..2dea5362b 100644 --- a/crates/nostr/src/event/tag/list.rs +++ b/crates/nostr/src/event/tag/list.rs @@ -28,7 +28,7 @@ use super::{Error, Tag}; use crate::nips::nip01::{Coordinate, Nip01Tag}; use crate::nips::nip40::Nip40Tag; use crate::nips::nip42::Nip42Tag; -use crate::{EventId, PublicKey, SingleLetterTag, TagKind, Timestamp}; +use crate::{EventId, PublicKey, SingleLetterTag, Timestamp}; /// Tags Indexes pub type TagsIndexes = BTreeMap<SingleLetterTag, BTreeSet<String>>; @@ -270,7 +270,7 @@ impl Tags { /// # Policy /// /// - Two tags are considered duplicates if: - /// 1) They have the same [`TagKind`] + /// 1) They have the same tag kind /// 2) They contain the same content (if applicable) /// /// - Among duplicates, the longest tag is retained; shorter ones are discarded. @@ -309,15 +309,15 @@ impl Tags { // Construct the dedup map #[cfg(feature = "std")] - let mut map: HashMap<(TagKind, Option<&str>), DedupVal> = + let mut map: HashMap<(&str, Option<&str>), DedupVal> = HashMap::with_capacity(self.list.len()); #[cfg(not(feature = "std"))] - let mut map: BTreeMap<(TagKind, Option<&str>), DedupVal> = BTreeMap::new(); + let mut map: BTreeMap<(&str, Option<&str>), DedupVal> = BTreeMap::new(); // Figure out which tags to keep for (idx, tag) in self.list.iter().enumerate() { // Construct dedup key - let key: (TagKind, Option<&str>) = (tag.kind(), tag.content()); + let key: (&str, Option<&str>) = (tag.kind(), tag.content()); // Check if key exists or not match map.entry(key) { @@ -377,18 +377,6 @@ impl Tags { self.list.iter() } - /// Get first tag that match [`TagKind`]. - #[inline] - pub fn find(&self, kind: TagKind) -> Option<&Tag> { - self.list.iter().find(|t| t.kind() == kind) - } - - /// Filter tags that match [`TagKind`]. - #[inline] - pub fn filter<'a>(&'a self, kind: TagKind<'a>) -> impl Iterator<Item = &'a Tag> { - self.list.iter().filter(move |t| t.kind() == kind) - } - /// Get as slice of tags #[inline] pub fn as_slice(&self) -> &[Tag] { @@ -404,7 +392,7 @@ impl Tags { /// Extract identifier (`d` tag), if exists. #[inline] pub fn identifier(&self) -> Option<String> { - let tag: &Tag = self.find(TagKind::d())?; + let tag: &Tag = self.iter().find(|t| t.kind() == "d")?; match Nip01Tag::try_from(tag) { Ok(Nip01Tag::Identifier(identifier)) => Some(identifier), @@ -416,7 +404,7 @@ impl Tags { /// /// <https://github.com/nostr-protocol/nips/blob/master/40.md> pub fn expiration(&self) -> Option<Timestamp> { - let tag: &Tag = self.find(TagKind::Expiration)?; + let tag: &Tag = self.iter().find(|t| t.kind() == "expiration")?; match Nip40Tag::try_from(tag) { Ok(Nip40Tag::Expiration(expiration)) => Some(expiration), @@ -427,7 +415,7 @@ impl Tags { /// Extract NIP42 challenge, if exists. #[inline] pub fn challenge(&self) -> Option<String> { - let tag: &Tag = self.find(TagKind::Challenge)?; + let tag: &Tag = self.iter().find(|t| t.kind() == "challenge")?; match Nip42Tag::try_from(tag) { Ok(Nip42Tag::Challenge(challenge)) => Some(challenge), @@ -438,7 +426,11 @@ impl Tags { /// Extract public keys from `p` tags. #[inline] pub fn public_keys(&self) -> impl Iterator<Item = PublicKey> + '_ { - self.filter(TagKind::p()).filter_map(|t| { + self.iter().filter_map(|t| { + if t.kind() != "p" { + return None; + } + let content = t.content()?; PublicKey::from_hex(content).ok() }) @@ -447,7 +439,11 @@ impl Tags { /// Extract event IDs from `e` tags. #[inline] pub fn event_ids(&self) -> impl Iterator<Item = EventId> + '_ { - self.filter(TagKind::e()).filter_map(|t| { + self.iter().filter_map(|t| { + if t.kind() != "e" { + return None; + } + let content = t.content()?; EventId::from_hex(content).ok() }) @@ -456,7 +452,11 @@ impl Tags { /// Extract coordinates from `a` tags. #[inline] pub fn coordinates(&self) -> impl Iterator<Item = Coordinate> + '_ { - self.filter(TagKind::a()).filter_map(|t| { + self.iter().filter_map(|t| { + if t.kind() != "a" { + return None; + } + let content = t.content()?; Coordinate::from_kpi_format(content).ok() }) @@ -465,7 +465,13 @@ impl Tags { /// Extract hashtags from `t` tags. #[inline] pub fn hashtags(&self) -> impl Iterator<Item = &str> + '_ { - self.filter(TagKind::t()).filter_map(|t| t.content()) + self.iter().filter_map(|t| { + if t.kind() != "t" { + return None; + } + + t.content() + }) } fn build_indexes(&self) -> TagsIndexes { @@ -605,7 +611,7 @@ mod tests { let list = vec![ Tag::protected(), - Tag::custom(TagKind::p(), empty_list.clone()), // Non standard p tag + Tag::custom("p", empty_list.clone()), // Non standard p tag Tag::public_key(pubkey1), Tag::public_key(pubkey2), Tag::event(event1), @@ -626,7 +632,7 @@ mod tests { let expected = vec![ Tag::protected(), - Tag::custom(TagKind::p(), empty_list), // Non standard p tag + Tag::custom("p", empty_list), // Non standard p tag long_p_tag_1.to_tag(), Tag::public_key(pubkey2), Tag::event(event1), diff --git a/crates/nostr/src/event/tag/mod.rs b/crates/nostr/src/event/tag/mod.rs index d56c038ac..04d460abb 100644 --- a/crates/nostr/src/event/tag/mod.rs +++ b/crates/nostr/src/event/tag/mod.rs @@ -4,13 +4,14 @@ //! Tag -use alloc::string::{String, ToString}; +use alloc::string::String; use alloc::vec; use alloc::vec::{IntoIter, Vec}; use core::cmp::Ordering; use core::fmt; use core::hash::{Hash, Hasher}; use core::ops::{Index, IndexMut}; +use core::str::FromStr; use serde::de::Error as DeserializerError; use serde::ser::SerializeSeq; @@ -19,13 +20,11 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; mod codec; pub mod cow; mod error; -pub mod kind; pub mod list; pub use self::codec::*; pub use self::cow::CowTag; pub use self::error::Error; -pub use self::kind::TagKind; pub use self::list::Tags; use super::id::EventId; use crate::nips::nip01::{Coordinate, Nip01Tag}; @@ -119,10 +118,9 @@ impl Tag { /// Get tag kind #[inline] - pub fn kind(&self) -> TagKind<'_> { + pub fn kind(&self) -> &str { // SAFETY: `buf` must not be empty, checked during parsing. - let key: &str = &self.buf[0]; - TagKind::from(key) + &self.buf[0] } /// Return the **first** tag value (index `1`), if exists. @@ -134,10 +132,7 @@ impl Tag { /// Get [`SingleLetterTag`] #[inline] pub fn single_letter_tag(&self) -> Option<SingleLetterTag> { - match self.kind() { - TagKind::SingleLetter(s) => Some(s), - _ => None, - } + SingleLetterTag::from_str(self.kind()).ok() } /// Get tag len @@ -331,14 +326,15 @@ impl Tag { /// Compose custom tag /// /// JSON: `["<kind>", "<value-1>", "<value-2>", ...]` - pub fn custom<I, S>(kind: TagKind, values: I) -> Self + pub fn custom<K, I, S>(kind: K, values: I) -> Self where + K: Into<String>, I: IntoIterator<Item = S>, S: Into<String>, { // Compose tag let mut buf: Vec<String> = Vec::with_capacity(1); - buf.push(kind.to_string()); + buf.push(kind.into()); buf.extend(values.into_iter().map(|v| v.into())); Self::new(buf) @@ -394,7 +390,7 @@ mod tests { use secp256k1::schnorr::Signature; use super::*; - use crate::{Alphabet, Event, JsonUtil, Kind, Timestamp}; + use crate::{Event, JsonUtil, Kind, Timestamp}; #[test] fn test_parse_empty_tag() { @@ -523,19 +519,12 @@ mod tests { fn test_tag_custom() { assert_eq!( vec!["r", "wss://atlas.nostr.land", ""], - Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)), - ["wss://atlas.nostr.land", ""] - ) - .to_vec() + Tag::custom("r", ["wss://atlas.nostr.land", ""]).to_vec() ); assert_eq!( Tag::parse(["r", "wss://atlas.nostr.land", ""]).unwrap(), - Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)), - ["wss://atlas.nostr.land", ""] - ) + Tag::custom("r", ["wss://atlas.nostr.land", ""]) ); assert_eq!( @@ -545,7 +534,7 @@ mod tests { "" ], Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)), + "r", [ "3dbee968d1ddcdf07521e246e405e1fbb549080f1f4ef4e42526c4528f124220", "" @@ -562,7 +551,7 @@ mod tests { ]) .unwrap(), Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::R)), + "r", [ "3dbee968d1ddcdf07521e246e405e1fbb549080f1f4ef4e42526c4528f124220", "" @@ -575,9 +564,9 @@ mod tests { fn test_hashtag() { assert_eq!( Tag::parse(["t", "Nostr"]).unwrap(), - Tag::custom(TagKind::t(), ["Nostr"]) + Tag::custom("t", ["Nostr"]) ); - assert_eq!(Tag::hashtag("Nostr"), Tag::custom(TagKind::t(), ["nostr"])); + assert_eq!(Tag::hashtag("Nostr"), Tag::custom("t", ["nostr"])); } } diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs index fc7bb15a3..cf2f4a4df 100644 --- a/crates/nostr/src/lib.rs +++ b/crates/nostr/src/lib.rs @@ -53,7 +53,7 @@ pub mod types; pub mod util; #[doc(hidden)] -pub use self::event::tag::{Tag, TagKind, Tags}; +pub use self::event::tag::{Tag, Tags}; #[doc(hidden)] pub use self::event::{Event, EventBuilder, EventId, Kind, UnsignedEvent}; #[doc(hidden)] diff --git a/crates/nostr/src/nips/nip13/mod.rs b/crates/nostr/src/nips/nip13/mod.rs index 70a3a1395..0631d43d7 100644 --- a/crates/nostr/src/nips/nip13/mod.rs +++ b/crates/nostr/src/nips/nip13/mod.rs @@ -210,7 +210,7 @@ pub mod tests { use super::*; use crate::Tag; #[cfg(feature = "std")] - use crate::{EventBuilder, PublicKey, TagKind}; + use crate::{EventBuilder, PublicKey}; #[test] fn test_parse_nonce_tag() { @@ -635,7 +635,7 @@ pub mod tests { .mine(&TestAdapter, NonZeroU8::new(2).unwrap()) .unwrap(); - let Some(nonce_tag) = unsigned.tags.find(TagKind::Nonce) else { + let Some(nonce_tag) = unsigned.tags.iter().find(|t| t.kind() == "nonce") else { panic!("nonce tag should be exist") }; @@ -680,7 +680,7 @@ pub mod tests { .await .unwrap(); - let Some(nonce_tag) = unsigned.tags.find(TagKind::Nonce) else { + let Some(nonce_tag) = unsigned.tags.iter().find(|t| t.kind() == "nonce") else { panic!("nonce tag should be exist") }; diff --git a/crates/nostr/src/nips/nip13/single_thread.rs b/crates/nostr/src/nips/nip13/single_thread.rs index 020993889..1d4c7824f 100644 --- a/crates/nostr/src/nips/nip13/single_thread.rs +++ b/crates/nostr/src/nips/nip13/single_thread.rs @@ -101,7 +101,7 @@ pub mod tests { .mine(&SingleThreadPow, NonZeroU8::new(2).unwrap()) .unwrap(); - let Some(nonce_tag) = unsigned.tags.find(TagKind::Nonce) else { + let Some(nonce_tag) = unsigned.tags.iter().find(|t| t.kind() == "nonce") else { panic!("nonce tag should be exist") }; diff --git a/crates/nostr/src/nips/nip35.rs b/crates/nostr/src/nips/nip35.rs index 087761490..1bba49aad 100644 --- a/crates/nostr/src/nips/nip35.rs +++ b/crates/nostr/src/nips/nip35.rs @@ -15,7 +15,7 @@ use alloc::vec::Vec; use hashes::sha1::Hash as Sha1Hash; use crate::types::url::Url; -use crate::{EventBuilder, Kind, Tag, TagKind}; +use crate::{EventBuilder, Kind, Tag}; /// Represents a file within a torrent. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -57,21 +57,18 @@ impl Torrent { tags.push(Tag::new(vec![String::from("title"), self.title])); - tags.push(Tag::custom(TagKind::x(), [self.info_hash.to_string()])); + tags.push(Tag::custom("x", [self.info_hash.to_string()])); for file in self.files.into_iter() { - tags.push(Tag::custom( - TagKind::File, - [file.name, file.size.to_string()], - )); + tags.push(Tag::custom("file", [file.name, file.size.to_string()])); } for tracker in self.trackers.into_iter() { - tags.push(Tag::custom(TagKind::Tracker, [tracker.to_string()])); + tags.push(Tag::custom("tracker", [tracker.to_string()])); } for cat in self.categories.into_iter() { - tags.push(Tag::custom(TagKind::i(), [format!("tcat:{cat}")])); + tags.push(Tag::custom("i", [format!("tcat:{cat}")])); } for tag in self.hashtags.into_iter() { diff --git a/crates/nostr/src/nips/nip53.rs b/crates/nostr/src/nips/nip53.rs index 9985b60cd..ce2410344 100644 --- a/crates/nostr/src/nips/nip53.rs +++ b/crates/nostr/src/nips/nip53.rs @@ -25,7 +25,7 @@ use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions use crate::key::{self, PublicKey}; use crate::types::image; use crate::types::url::{self, RelayUrl, Url}; -use crate::{Event, EventId, ImageDimensions, Kind, TagKind, Timestamp, event}; +use crate::{Event, EventId, ImageDimensions, Kind, Timestamp, event}; const TITLE: &str = "title"; const SUMMARY: &str = "summary"; @@ -876,7 +876,7 @@ impl TryFrom<Vec<Tag>> for LiveEvent { fn try_from(tags: Vec<Tag>) -> Result<Self, Self::Error> { let id: String = tags .iter() - .find(|t| t.kind() == TagKind::d()) + .find(|t| t.kind() == "d") .and_then(|t| t.content()) .map(|value| value.to_string()) .ok_or(Error::DescriptionMissing)?; diff --git a/crates/nostr/src/nips/nip60.rs b/crates/nostr/src/nips/nip60.rs index f8bdc13fb..be2613b22 100644 --- a/crates/nostr/src/nips/nip60.rs +++ b/crates/nostr/src/nips/nip60.rs @@ -10,7 +10,7 @@ use core::str::FromStr; use serde::{Deserialize, Serialize}; use super::nip44; -use crate::event::{self, Event, EventId, TagKind, tag}; +use crate::event::{self, Event, EventId, tag}; #[cfg(all(feature = "std", feature = "os-rng"))] use crate::event::{EventBuilder, Kind, Tag}; use crate::key::{PublicKey, SecretKey}; @@ -449,7 +449,7 @@ impl SpendingHistory { // Add created event references (encrypted) for event_id in &self.created { let tag: Tag = Tag::custom( - TagKind::e(), + "e", [ event_id.to_hex(), String::new(), @@ -462,7 +462,7 @@ impl SpendingHistory { // Add destroyed event references (encrypted) for event_id in &self.destroyed { let tag: Tag = Tag::custom( - TagKind::e(), + "e", [ event_id.to_hex(), String::new(), @@ -542,7 +542,8 @@ impl QuoteEvent { // Extract mint URL from tags let mint: Url = event .tags - .find(TagKind::custom(MINT)) + .iter() + .find(|t| t.kind() == MINT) .and_then(|tag| tag.content()) .ok_or(Error::MissingMintTag)? .parse() @@ -590,7 +591,7 @@ impl QuoteEvent { let mut tags: Vec<Tag> = Vec::with_capacity(2); // Add mint tag - tags.push(Tag::custom(TagKind::custom(MINT), [self.mint.as_str()])); + tags.push(Tag::custom(MINT, [self.mint.as_str()])); // Add NIP-40 expiration tag (current time + 2 weeks) let expiration: Timestamp = Timestamp::now() + 14 * 24 * 60 * 60; // 2 weeks in seconds diff --git a/gossip/nostr-gossip-memory/src/store.rs b/gossip/nostr-gossip-memory/src/store.rs index 518fd1af0..367949645 100644 --- a/gossip/nostr-gossip-memory/src/store.rs +++ b/gossip/nostr-gossip-memory/src/store.rs @@ -10,7 +10,7 @@ use nostr::nips::nip01::Nip01Tag; use nostr::nips::nip17; use nostr::nips::nip65::{self, RelayMetadata}; use nostr::util::BoxedFuture; -use nostr::{Event, Kind, PublicKey, RelayUrl, TagKind, Timestamp}; +use nostr::{Event, Kind, PublicKey, RelayUrl, Timestamp}; use nostr_gossip::error::GossipError; use nostr_gossip::flags::GossipFlags; use nostr_gossip::{ @@ -126,11 +126,13 @@ impl NostrGossipMemory { } // Extract hints _ => { - for tag in event - .tags - .filter(TagKind::p()) - .filter_map(|t| Nip01Tag::try_from(t).ok()) - { + for tag in event.tags.iter().filter_map(|t| { + if t.kind() != "p" { + return None; + } + + Nip01Tag::try_from(t).ok() + }) { if let Nip01Tag::PublicKey { public_key, relay_hint: Some(relay_hint), diff --git a/gossip/nostr-gossip-sqlite/src/store.rs b/gossip/nostr-gossip-sqlite/src/store.rs index 3930bbc3c..f11c21217 100644 --- a/gossip/nostr-gossip-sqlite/src/store.rs +++ b/gossip/nostr-gossip-sqlite/src/store.rs @@ -9,7 +9,7 @@ use nostr::nips::nip01::Nip01Tag; use nostr::nips::nip17; use nostr::nips::nip65::{self, RelayMetadata}; use nostr::util::BoxedFuture; -use nostr::{Event, Kind, PublicKey, RelayUrl, TagKind, Timestamp}; +use nostr::{Event, Kind, PublicKey, RelayUrl, Timestamp}; use nostr_gossip::error::GossipError; use nostr_gossip::flags::GossipFlags; use nostr_gossip::{ @@ -557,11 +557,13 @@ where } fn update_hints(tx: &Transaction<'_>, event: &Event) -> Result<(), Error> { - for tag in event - .tags - .filter(TagKind::p()) - .filter_map(|t| Nip01Tag::try_from(t).ok()) - { + for tag in event.tags.iter().filter_map(|t| { + if t.kind() != "p" { + return None; + } + + Nip01Tag::try_from(t).ok() + }) { if let Nip01Tag::PublicKey { public_key, relay_hint: Some(relay_url), From de3800c50edfa070b765babd449dddba2acbc5ba Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 12 May 2026 10:52:58 +0200 Subject: [PATCH 39/40] nostr: add NIP-73 tags Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/nips/nip22.rs | 44 ++++----- crates/nostr/src/nips/nip73.rs | 168 ++++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 31 deletions(-) diff --git a/crates/nostr/src/nips/nip22.rs b/crates/nostr/src/nips/nip22.rs index af09d1006..87376b082 100644 --- a/crates/nostr/src/nips/nip22.rs +++ b/crates/nostr/src/nips/nip22.rs @@ -219,20 +219,15 @@ impl TagCodec for Nip22Tag { hint, uppercase, } => { - let mut tag: Vec<String> = Vec::with_capacity(2 + hint.is_some() as usize); + // Serialize as lowercase "i" tag + let mut tag: Tag = nip73::serialize_i_tag(content, hint.as_ref()); - tag.push(if *uppercase { - String::from("I") - } else { - String::from("i") - }); - tag.push(content.to_string()); - - if let Some(hint) = hint { - tag.push(hint.to_string()); + // Replace the "i" tag with the "I" tag, if uppercase. + if *uppercase { + tag[0] = String::from("I"); } - Tag::new(tag) + tag } Self::Kind { kind, uppercase } => Tag::new(vec![ if *uppercase { @@ -242,14 +237,17 @@ impl TagCodec for Nip22Tag { }, kind.to_string(), ]), - Self::Nip73Kind { kind, uppercase } => Tag::new(vec![ + Self::Nip73Kind { kind, uppercase } => { + // Serialize as lowercase "k" tag + let mut tag: Tag = nip73::serialize_k_tag(kind); + + // Replace the "k" tag with the "K" tag, if uppercase. if *uppercase { - String::from("K") - } else { - String::from("k") - }, - kind.to_string(), - ]), + tag[0] = String::from("K"); + } + + tag + } Self::PublicKey { public_key, relay_hint, @@ -668,18 +666,12 @@ where }) } -fn parse_i_tag<T, S>(mut iter: T, uppercase: bool) -> Result<Nip22Tag, Error> +fn parse_i_tag<T, S>(iter: T, uppercase: bool) -> Result<Nip22Tag, Error> where T: Iterator<Item = S>, S: AsRef<str>, { - let content: S = iter.next().ok_or(Error::MissingExternalContent)?; - let content: ExternalContentId = ExternalContentId::from_str(content.as_ref())?; - - let hint: Option<Url> = match iter.next() { - Some(hint) => Some(Url::parse(hint.as_ref())?), - None => None, - }; + let (content, hint) = nip73::parse_i_tag(iter)?; Ok(Nip22Tag::ExternalContent { content, diff --git a/crates/nostr/src/nips/nip73.rs b/crates/nostr/src/nips/nip73.rs index f9e06daa1..120fa5e97 100644 --- a/crates/nostr/src/nips/nip73.rs +++ b/crates/nostr/src/nips/nip73.rs @@ -2,14 +2,18 @@ // Copyright (c) 2023-2025 Rust Nostr Developers // Distributed under the MIT software license -//! NIP73: External Content IDs +//! NIP-73: External Content IDs //! //! <https://github.com/nostr-protocol/nips/blob/master/73.md> use alloc::string::{String, ToString}; +use alloc::vec; +use alloc::vec::Vec; use core::fmt; use core::str::FromStr; +use super::util::{take_and_parse_from_str, take_and_parse_optional_from_str}; +use crate::event::tag::{Tag, TagCodec, TagCodecError, impl_tag_codec_conversions}; use crate::types::Url; const HASHTAG: &str = "#"; @@ -26,6 +30,10 @@ const BLOCKCHAIN_ADDR: &str = ":address:"; /// NIP73 error #[derive(Debug, PartialEq, Eq)] pub enum Error { + /// URL error + Url(url::ParseError), + /// Codec error + Codec(TagCodecError), /// Invalid external content InvalidExternalContent, /// Invalid NIP-73 kind @@ -37,12 +45,26 @@ impl core::error::Error for Error {} impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Url(e) => e.fmt(f), + Self::Codec(e) => e.fmt(f), Self::InvalidExternalContent => f.write_str("invalid external content ID"), Self::InvalidNip73Kind => f.write_str("Invalid NIP-73 kind"), } } } +impl From<url::ParseError> for Error { + fn from(e: url::ParseError) -> Self { + Self::Url(e) + } +} + +impl From<TagCodecError> for Error { + fn from(e: TagCodecError) -> Self { + Self::Codec(e) + } +} + /// External Content ID #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ExternalContentId { @@ -92,7 +114,7 @@ pub enum Nip73Kind { /// Books kind "isbn" Book, /// Geohashes kind "geo" - Geohashe, + Geohash, /// Movies kind "isan" Movie, /// Papers kind "doi" @@ -116,7 +138,7 @@ impl fmt::Display for Nip73Kind { match self { Self::Url => f.write_str("web"), Self::Book => f.write_str("isbn"), - Self::Geohashe => f.write_str("geo"), + Self::Geohash => f.write_str("geo"), Self::Movie => f.write_str("isan"), Self::Paper => f.write_str("doi"), Self::Hashtag => HASHTAG.fmt(f), @@ -180,7 +202,7 @@ impl FromStr for Nip73Kind { match nip73_kind { "web" => Ok(Self::Url), "isbn" => Ok(Self::Book), - "geo" => Ok(Self::Geohashe), + "geo" => Ok(Self::Geohash), "isan" => Ok(Self::Movie), "doi" => Ok(Self::Paper), HASHTAG => Ok(Self::Hashtag), @@ -276,7 +298,7 @@ impl ExternalContentId { match self { Self::Url(_) => Nip73Kind::Url, Self::Hashtag(_) => Nip73Kind::Hashtag, - Self::Geohash(_) => Nip73Kind::Geohashe, + Self::Geohash(_) => Nip73Kind::Geohash, Self::Book(_) => Nip73Kind::Book, Self::PodcastFeed(_) => Nip73Kind::PodcastFeed, Self::PodcastEpisode(_) => Nip73Kind::PodcastEpisode, @@ -300,6 +322,86 @@ fn extract_chain_id(chain: &str) -> (String, Option<String>) { } } +/// Standardized NIP-73 tags +/// +/// <https://github.com/nostr-protocol/nips/blob/master/73.md> +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Nip73Tag { + /// `i` tag + ExternalContent { + /// External content + content: ExternalContentId, + /// Optional URL hint + hint: Option<Url>, + }, + /// `k` tag + Kind(Nip73Kind), +} + +impl TagCodec for Nip73Tag { + type Error = Error; + + fn parse<I, S>(tag: I) -> Result<Self, Self::Error> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut iter = tag.into_iter(); + let kind: S = iter.next().ok_or(TagCodecError::missing_tag_kind())?; + + match kind.as_ref() { + "i" => { + let (content, hint) = parse_i_tag(iter)?; + Ok(Self::ExternalContent { content, hint }) + } + "k" => { + let kind: Nip73Kind = take_and_parse_from_str::<_, _, _, Error>(&mut iter, "kind")?; + Ok(Self::Kind(kind)) + } + _ => Err(TagCodecError::Unknown.into()), + } + } + + fn to_tag(&self) -> Tag { + match self { + Self::ExternalContent { content, hint } => serialize_i_tag(content, hint.as_ref()), + Self::Kind(kind) => serialize_k_tag(kind), + } + } +} + +impl_tag_codec_conversions!(Nip73Tag); + +pub(super) fn parse_i_tag<T, S>(mut iter: T) -> Result<(ExternalContentId, Option<Url>), Error> +where + T: Iterator<Item = S>, + S: AsRef<str>, +{ + let content: ExternalContentId = + take_and_parse_from_str::<_, _, _, Error>(&mut iter, "content")?; + let hint: Option<Url> = take_and_parse_optional_from_str(&mut iter)?; + + Ok((content, hint)) +} + +pub(super) fn serialize_i_tag(content: &ExternalContentId, hint: Option<&Url>) -> Tag { + let mut tag: Vec<String> = Vec::with_capacity(2 + hint.is_some() as usize); + + tag.push(String::from("i")); + tag.push(content.to_string()); + + if let Some(hint) = hint { + tag.push(hint.to_string()); + } + + Tag::new(tag) +} + +#[inline] +pub(super) fn serialize_k_tag(kind: &Nip73Kind) -> Tag { + Tag::new(vec![String::from("k"), kind.to_string()]) +} + #[cfg(test)] mod tests { use super::*; @@ -462,4 +564,60 @@ mod tests { Err(Error::InvalidExternalContent) ); } + + #[test] + fn test_i_tag() { + let raw = [ + "i", + "https://myblog.example.com/post/2012-03-27/hello-world", + ]; + let tag = Nip73Tag::parse(raw).unwrap(); + + assert_eq!( + tag, + Nip73Tag::ExternalContent { + content: ExternalContentId::Url( + Url::parse("https://myblog.example.com/post/2012-03-27/hello-world").unwrap() + ), + hint: None + } + ); + + assert_eq!(tag.to_tag().as_slice(), &raw); + } + + #[test] + fn test_i_tag_with_hint() { + let raw = [ + "i", + "podcast:item:guid:d98d189b-dc7b-45b1-8720-d4b98690f31f", + "https://fountain.fm/episode/z1y9TMQRuqXl2awyrQxg", + ]; + let tag = Nip73Tag::parse(raw).unwrap(); + + assert_eq!( + tag, + Nip73Tag::ExternalContent { + content: ExternalContentId::PodcastEpisode( + "d98d189b-dc7b-45b1-8720-d4b98690f31f".to_string() + ), + hint: Some(Url::parse("https://fountain.fm/episode/z1y9TMQRuqXl2awyrQxg").unwrap()) + } + ); + + assert_eq!(tag.to_tag().as_slice(), &raw); + } + + #[test] + fn test_k_tag() { + let raw = ["k", "bitcoin:address"]; + let tag = Nip73Tag::parse(raw).unwrap(); + + assert_eq!( + tag, + Nip73Tag::Kind(Nip73Kind::BlockchainAddress(String::from("bitcoin"))) + ); + + assert_eq!(tag.to_tag().as_slice(), &raw); + } } From 6eb03213c7d3d9eaaa9b74c142f2b8eb79445af6 Mon Sep 17 00:00:00 2001 From: Yuki Kishimoto <yukikishimoto@protonmail.com> Date: Tue, 12 May 2026 16:34:32 +0200 Subject: [PATCH 40/40] nostr: refactor NIP-22 tag serialization with `maybe_uppercase` helper Co-authored-by: Awiteb <a@4rs.nl> Signed-off-by: Yuki Kishimoto <yukikishimoto@protonmail.com> --- crates/nostr/src/nips/nip22.rs | 76 ++++++++++------------------------ 1 file changed, 22 insertions(+), 54 deletions(-) diff --git a/crates/nostr/src/nips/nip22.rs b/crates/nostr/src/nips/nip22.rs index 87376b082..d586dc7bc 100644 --- a/crates/nostr/src/nips/nip22.rs +++ b/crates/nostr/src/nips/nip22.rs @@ -186,49 +186,24 @@ impl TagCodec for Nip22Tag { coordinate, relay_hint, uppercase, - } => { - // Serialize as lowercase "a" tag - let mut tag: Tag = nip01::serialize_a_tag(coordinate, relay_hint.as_ref()); - - // Replace the "a" tag with the "A" tag, if uppercase. - if *uppercase { - tag[0] = String::from("A"); - } - - tag - } + } => maybe_uppercase( + nip01::serialize_a_tag(coordinate, relay_hint.as_ref()), + *uppercase, + ), Self::Event { id, relay_hint, public_key, uppercase, - } => { - // Serialize as lowercase "e" tag - let mut tag: Tag = - nip01::serialize_e_tag(id, relay_hint.as_ref(), public_key.as_ref()); - - // Replace the "e" tag with the "E" tag, if uppercase. - if *uppercase { - tag[0] = String::from("E"); - } - - tag - } + } => maybe_uppercase( + nip01::serialize_e_tag(id, relay_hint.as_ref(), public_key.as_ref()), + *uppercase, + ), Self::ExternalContent { content, hint, uppercase, - } => { - // Serialize as lowercase "i" tag - let mut tag: Tag = nip73::serialize_i_tag(content, hint.as_ref()); - - // Replace the "i" tag with the "I" tag, if uppercase. - if *uppercase { - tag[0] = String::from("I"); - } - - tag - } + } => maybe_uppercase(nip73::serialize_i_tag(content, hint.as_ref()), *uppercase), Self::Kind { kind, uppercase } => Tag::new(vec![ if *uppercase { String::from("K") @@ -238,31 +213,16 @@ impl TagCodec for Nip22Tag { kind.to_string(), ]), Self::Nip73Kind { kind, uppercase } => { - // Serialize as lowercase "k" tag - let mut tag: Tag = nip73::serialize_k_tag(kind); - - // Replace the "k" tag with the "K" tag, if uppercase. - if *uppercase { - tag[0] = String::from("K"); - } - - tag + maybe_uppercase(nip73::serialize_k_tag(kind), *uppercase) } Self::PublicKey { public_key, relay_hint, uppercase, - } => { - // Serialize as lowercase "p" tag - let mut tag: Tag = nip01::serialize_p_tag(public_key, relay_hint.as_ref()); - - // Replace the "p" tag with the "P" tag, if uppercase. - if *uppercase { - tag[0] = String::from("P"); - } - - tag - } + } => maybe_uppercase( + nip01::serialize_p_tag(public_key, relay_hint.as_ref()), + *uppercase, + ), } } } @@ -715,6 +675,14 @@ where }) } +#[inline] +fn maybe_uppercase(mut tag: Tag, uppercase: bool) -> Tag { + if uppercase { + tag[0] = tag[0].to_ascii_uppercase(); + } + tag +} + #[cfg(all(test, feature = "std", feature = "os-rng"))] mod tests { use super::*;