tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 3766efb9e89180cf33f728d7621c90911c11ead9
parent 67c958be4a98c0b200f7ae3a6d9eea65d3cc2292
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 03:59:38 -0700

nips: add label parser

Diffstat:
Mcrates/tangle_nips/src/lib.rs | 328++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 318 insertions(+), 10 deletions(-)

diff --git a/crates/tangle_nips/src/lib.rs b/crates/tangle_nips/src/lib.rs @@ -12,6 +12,7 @@ pub const NIP25_REACTION_KIND: u32 = 7; pub const NIP23_LONG_FORM_KIND: u32 = 30_023; pub const NIP23_LONG_FORM_DRAFT_KIND: u32 = 30_024; pub const NIP7D_THREAD_KIND: u32 = 11; +pub const NIP32_LABEL_KIND: u32 = 1_985; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedTag { @@ -952,6 +953,196 @@ pub fn parse_forum_thread_event(event: &Event) -> Result<Option<ForumThreadEvent })) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LabelTarget { + Event(EventId), + Pubkey(PublicKeyHex), + Address(AddressCoordinate), + Relay(String), + Topic(String), +} + +impl LabelTarget { + pub fn target_type(&self) -> &'static str { + match self { + Self::Event(_) => "event", + Self::Pubkey(_) => "pubkey", + Self::Address(_) => "address", + Self::Relay(_) => "relay", + Self::Topic(_) => "topic", + } + } + + pub fn target_ref(&self) -> String { + match self { + Self::Event(event_id) => event_id.as_str().to_owned(), + Self::Pubkey(pubkey) => pubkey.as_str().to_owned(), + Self::Address(address) => address.key().to_string(), + Self::Relay(relay) | Self::Topic(relay) => relay.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LabelValue { + value: String, + namespace: String, +} + +impl LabelValue { + pub fn value(&self) -> &str { + &self.value + } + + pub fn namespace(&self) -> &str { + &self.namespace + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LabelEvent { + event_id: EventId, + pubkey: PublicKeyHex, + created_at: UnixTimestamp, + content: String, + namespaces: Vec<String>, + labels: Vec<LabelValue>, + targets: Vec<LabelTarget>, +} + +impl LabelEvent { + pub fn event_id(&self) -> &EventId { + &self.event_id + } + + pub fn pubkey(&self) -> &PublicKeyHex { + &self.pubkey + } + + pub fn created_at(&self) -> UnixTimestamp { + self.created_at + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn namespaces(&self) -> &[String] { + &self.namespaces + } + + pub fn labels(&self) -> &[LabelValue] { + &self.labels + } + + pub fn targets(&self) -> &[LabelTarget] { + &self.targets + } +} + +pub fn parse_label_event(event: &Event) -> Result<Option<LabelEvent>, String> { + if event.unsigned().kind().as_u32() != NIP32_LABEL_KIND { + return Ok(None); + } + let namespaces = parse_label_namespaces(event)?; + let labels = parse_label_values(event, &namespaces)?; + if labels.is_empty() { + return Err("label event must include at least one l tag".to_owned()); + } + let targets = parse_label_targets(event)?; + if targets.is_empty() { + return Err("label event must target at least one e p a r or t tag".to_owned()); + } + Ok(Some(LabelEvent { + event_id: event.id().clone(), + pubkey: event.unsigned().pubkey().clone(), + created_at: event.unsigned().created_at(), + content: event.unsigned().content().to_owned(), + namespaces, + labels, + targets, + })) +} + +fn parse_label_namespaces(event: &Event) -> Result<Vec<String>, String> { + let mut namespaces = Vec::new(); + for tag in matching_tags(event, "L") { + match tag.values() { + [value] if !value.is_empty() => namespaces.push(value.clone()), + [..] => { + return Err( + "label namespace L tag must include exactly one non-empty value".to_owned(), + ); + } + } + } + namespaces.sort(); + namespaces.dedup(); + Ok(namespaces) +} + +fn parse_label_values(event: &Event, namespaces: &[String]) -> Result<Vec<LabelValue>, String> { + let mut labels = Vec::new(); + for tag in matching_tags(event, "l") { + let values = tag.values(); + let value = values + .first() + .ok_or_else(|| "label l tag must include a value".to_owned())?; + if value.is_empty() { + return Err("label l value must not be empty".to_owned()); + } + if values.len() > 2 { + return Err("label l tag must include at most value and namespace".to_owned()); + } + let namespace = match values.get(1) { + Some(namespace) if namespace.is_empty() => { + return Err("label l namespace must not be empty".to_owned()); + } + Some(namespace) => namespace.clone(), + None if namespaces.is_empty() => "ugc".to_owned(), + None => return Err("label l tag must include a namespace matching an L tag".to_owned()), + }; + if !namespaces.is_empty() && !namespaces.contains(&namespace) { + return Err("label l namespace must match an L tag".to_owned()); + } + labels.push(LabelValue { + value: value.clone(), + namespace, + }); + } + Ok(labels) +} + +fn parse_label_targets(event: &Event) -> Result<Vec<LabelTarget>, String> { + let mut targets = Vec::new(); + for value in single_letter_values_for(event, "e")? { + targets.push(LabelTarget::Event(EventId::new(&value)?)); + } + for value in single_letter_values_for(event, "p")? { + targets.push(LabelTarget::Pubkey(parse_pubkey_value( + &value, + "label target", + "pubkey", + )?)); + } + for value in single_letter_values_for(event, "a")? { + targets.push(LabelTarget::Address(AddressCoordinate::from_str(&value)?)); + } + for value in single_letter_values_for(event, "r")? { + if value.is_empty() { + return Err("label relay target must not be empty".to_owned()); + } + targets.push(LabelTarget::Relay(value)); + } + for value in single_letter_values_for(event, "t")? { + if value.is_empty() { + return Err("label topic target must not be empty".to_owned()); + } + targets.push(LabelTarget::Topic(value)); + } + Ok(targets) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ListingKind { Public, @@ -1705,17 +1896,18 @@ fn listing_kind_for_event(event: &Event) -> Option<ListingKind> { #[cfg(test)] mod tests { use super::{ - CommentTarget, DeletionTarget, FulfillmentMethod, ListingEffectiveStatus, ListingKind, - ListingProjectionEvaluation, ListingUnit, LongFormKind, NIP7D_THREAD_KIND, + CommentTarget, DeletionTarget, FulfillmentMethod, LabelTarget, ListingEffectiveStatus, + ListingKind, ListingProjectionEvaluation, ListingUnit, LongFormKind, NIP7D_THREAD_KIND, NIP22_COMMENT_KIND, NIP23_LONG_FORM_DRAFT_KIND, NIP23_LONG_FORM_KIND, NIP25_REACTION_KIND, - NIP99_PUBLIC_LISTING_KIND, ReactionValue, evaluate_listing_projection, matching_tags, - optional_tag_value, optional_tag_values, parse_comment_event, parse_deletion_request, - parse_forum_thread_event, parse_listing_fulfillment, parse_listing_identity, - parse_listing_location, parse_listing_price, parse_listing_status, parse_listing_taxonomy, - parse_listing_text, parse_listing_unit, parse_long_form_event, parse_nip50_filter_search, - parse_nip50_search, parse_reaction_event, parse_relay_auth_event, parse_required_u64_tag, - parse_u64_field, repeated_or_missing_policy_boundary, required_tag_value, - required_tag_values, single_letter_tag_values, single_letter_values_for, tag_count, + NIP32_LABEL_KIND, NIP99_PUBLIC_LISTING_KIND, ReactionValue, evaluate_listing_projection, + matching_tags, optional_tag_value, optional_tag_values, parse_comment_event, + parse_deletion_request, parse_forum_thread_event, parse_label_event, + parse_listing_fulfillment, parse_listing_identity, parse_listing_location, + parse_listing_price, parse_listing_status, parse_listing_taxonomy, parse_listing_text, + parse_listing_unit, parse_long_form_event, parse_nip50_filter_search, parse_nip50_search, + parse_reaction_event, parse_relay_auth_event, parse_required_u64_tag, parse_u64_field, + repeated_or_missing_policy_boundary, required_tag_value, required_tag_values, + single_letter_tag_values, single_letter_values_for, tag_count, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, @@ -2377,6 +2569,122 @@ mod tests { } #[test] + fn label_parser_extracts_namespaced_labels_and_targets() { + let event_id = "8".repeat(EventId::HEX_LENGTH); + let pubkey = "9".repeat(PublicKeyHex::HEX_LENGTH); + let address = format!("30023:{pubkey}:harvest-notes"); + let event = event_with_kind_tags_and_content( + NIP32_LABEL_KIND.into(), + vec![ + Tag::from_parts("L", &["com.radroots.moderation"]).expect("L"), + Tag::from_parts("l", &["approve", "com.radroots.moderation"]).expect("l"), + Tag::from_parts("e", &[&event_id, "wss://relay.radroots.test"]).expect("e"), + Tag::from_parts("p", &[&pubkey, "wss://relay.radroots.test"]).expect("p"), + Tag::from_parts("a", &[&address]).expect("a"), + Tag::from_parts("r", &["wss://relay.radroots.test"]).expect("r"), + Tag::from_parts("t", &["market"]).expect("t"), + ], + "moderator note", + ); + + let label = parse_label_event(&event).expect("parse").expect("label"); + + assert_eq!(label.event_id(), event.id()); + assert_eq!(label.pubkey(), event.unsigned().pubkey()); + assert_eq!(label.created_at(), event.unsigned().created_at()); + assert_eq!(label.content(), "moderator note"); + assert_eq!(label.namespaces(), &["com.radroots.moderation".to_owned()]); + assert_eq!(label.labels()[0].value(), "approve"); + assert_eq!(label.labels()[0].namespace(), "com.radroots.moderation"); + assert_eq!(label.targets().len(), 5); + assert_eq!(label.targets()[0].target_type(), "event"); + assert_eq!(label.targets()[0].target_ref(), event_id); + assert_eq!(label.targets()[1].target_type(), "pubkey"); + assert_eq!(label.targets()[1].target_ref(), pubkey); + assert!( + matches!(&label.targets()[2], LabelTarget::Address(parsed) if parsed.key().to_string() == address) + ); + assert_eq!(label.targets()[3].target_type(), "relay"); + assert_eq!(label.targets()[4].target_type(), "topic"); + } + + #[test] + fn label_parser_defaults_to_ugc_namespace_and_ignores_other_kinds() { + let target = "8".repeat(EventId::HEX_LENGTH); + let event = event_with_kind_and_tags( + NIP32_LABEL_KIND.into(), + vec![ + Tag::from_parts("l", &["needs-review"]).expect("l"), + Tag::from_parts("e", &[&target]).expect("e"), + ], + ); + let note = event_with_kind_and_tags(1, vec![Tag::from_parts("l", &["topic"]).expect("l")]); + + let label = parse_label_event(&event).expect("parse").expect("label"); + + assert_eq!(label.namespaces(), &[] as &[String]); + assert_eq!(label.labels()[0].namespace(), "ugc"); + assert_eq!(parse_label_event(&note), Ok(None)); + } + + #[test] + fn label_parser_rejects_missing_labels_targets_and_bad_namespaces() { + let target = "8".repeat(EventId::HEX_LENGTH); + let missing_label = event_with_kind_and_tags( + NIP32_LABEL_KIND.into(), + vec![Tag::from_parts("e", &[&target]).expect("e")], + ); + let missing_target = event_with_kind_and_tags( + NIP32_LABEL_KIND.into(), + vec![Tag::from_parts("l", &["approve"]).expect("l")], + ); + let unmatched_namespace = event_with_kind_and_tags( + NIP32_LABEL_KIND.into(), + vec![ + Tag::from_parts("L", &["one"]).expect("L"), + Tag::from_parts("l", &["approve", "two"]).expect("l"), + Tag::from_parts("e", &[&target]).expect("e"), + ], + ); + let missing_label_namespace = event_with_kind_and_tags( + NIP32_LABEL_KIND.into(), + vec![ + Tag::from_parts("L", &["one"]).expect("L"), + Tag::from_parts("l", &["approve"]).expect("l"), + Tag::from_parts("e", &[&target]).expect("e"), + ], + ); + let bad_target = event_with_kind_and_tags( + NIP32_LABEL_KIND.into(), + vec![ + Tag::from_parts("l", &["approve"]).expect("l"), + Tag::from_parts("e", &["bad"]).expect("e"), + ], + ); + + assert_eq!( + parse_label_event(&missing_label).expect_err("missing label"), + "label event must include at least one l tag" + ); + assert_eq!( + parse_label_event(&missing_target).expect_err("missing target"), + "label event must target at least one e p a r or t tag" + ); + assert_eq!( + parse_label_event(&unmatched_namespace).expect_err("unmatched namespace"), + "label l namespace must match an L tag" + ); + assert_eq!( + parse_label_event(&missing_label_namespace).expect_err("missing namespace"), + "label l tag must include a namespace matching an L tag" + ); + assert_eq!( + parse_label_event(&bad_target).expect_err("bad target"), + "event id must be 64 characters, got 3" + ); + } + + #[test] fn deletion_request_parser_extracts_event_and_address_targets() { let target_event_id = "2".repeat(EventId::HEX_LENGTH); let target_pubkey = "3".repeat(PublicKeyHex::HEX_LENGTH);