tangle


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

commit 9bee1f912708752f3210890f38c17f45d127da62
parent b14904ce5945208641eef0ea6205a94ed14c8200
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 11:57:12 -0700

nips: add seller profile parser

Diffstat:
Mcrates/tangle_nips/Cargo.toml | 4+---
Mcrates/tangle_nips/src/lib.rs | 264++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 255 insertions(+), 13 deletions(-)

diff --git a/crates/tangle_nips/Cargo.toml b/crates/tangle_nips/Cargo.toml @@ -8,10 +8,8 @@ license.workspace = true description = "MVP NIP parsers and projection contracts for tangle" [dependencies] -tangle_protocol = { path = "../tangle_protocol" } - -[dev-dependencies] serde_json = "1" +tangle_protocol = { path = "../tangle_protocol" } [lints] workspace = true diff --git a/crates/tangle_nips/src/lib.rs b/crates/tangle_nips/src/lib.rs @@ -5,6 +5,7 @@ use tangle_protocol::{ AddressCoordinate, DTag, Event, EventId, Filter, PublicKeyHex, TagName, UnixTimestamp, }; +pub const NIP01_METADATA_KIND: u32 = 0; pub const NIP99_PUBLIC_LISTING_KIND: u32 = 30_402; pub const NIP99_DRAFT_LISTING_KIND: u32 = 30_403; pub const NIP22_COMMENT_KIND: u32 = 1_111; @@ -1367,6 +1368,141 @@ fn parse_label_targets(event: &Event) -> Result<Vec<LabelTarget>, String> { Ok(targets) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SellerProfileMetadata { + name: Option<String>, + display_name: Option<String>, + about: Option<String>, + picture: Option<String>, + website: Option<String>, + nip05: Option<String>, + lud16: Option<String>, +} + +impl SellerProfileMetadata { + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + pub fn display_name(&self) -> Option<&str> { + self.display_name.as_deref() + } + + pub fn about(&self) -> Option<&str> { + self.about.as_deref() + } + + pub fn picture(&self) -> Option<&str> { + self.picture.as_deref() + } + + pub fn website(&self) -> Option<&str> { + self.website.as_deref() + } + + pub fn nip05(&self) -> Option<&str> { + self.nip05.as_deref() + } + + pub fn lud16(&self) -> Option<&str> { + self.lud16.as_deref() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SellerProfileEvent { + event_id: EventId, + pubkey: PublicKeyHex, + created_at: UnixTimestamp, + metadata: SellerProfileMetadata, + regions: Vec<String>, + categories: Vec<String>, + trust_markers: Vec<String>, +} + +impl SellerProfileEvent { + 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 metadata(&self) -> &SellerProfileMetadata { + &self.metadata + } + + pub fn regions(&self) -> &[String] { + &self.regions + } + + pub fn categories(&self) -> &[String] { + &self.categories + } + + pub fn trust_markers(&self) -> &[String] { + &self.trust_markers + } +} + +pub fn parse_seller_profile_event(event: &Event) -> Result<Option<SellerProfileEvent>, String> { + if event.unsigned().kind().as_u32() != NIP01_METADATA_KIND { + return Ok(None); + } + let metadata = parse_seller_profile_metadata(event.unsigned().content())?; + Ok(Some(SellerProfileEvent { + event_id: event.id().clone(), + pubkey: event.unsigned().pubkey().clone(), + created_at: event.unsigned().created_at(), + metadata, + regions: collect_normalized_tag_values(event, "region", "seller profile region")?, + categories: collect_normalized_tag_values(event, "category", "seller profile category")?, + trust_markers: collect_normalized_tag_values(event, "trust", "seller profile trust")?, + })) +} + +fn parse_seller_profile_metadata(content: &str) -> Result<SellerProfileMetadata, String> { + let value = serde_json::from_str::<serde_json::Value>(content) + .map_err(|error| format!("seller profile metadata JSON is invalid: {error}"))?; + let object = value + .as_object() + .ok_or_else(|| "seller profile metadata must be a JSON object".to_owned())?; + Ok(SellerProfileMetadata { + name: optional_metadata_string(object, "name")?, + display_name: optional_metadata_string(object, "display_name")?, + about: optional_metadata_string(object, "about")?, + picture: optional_metadata_string(object, "picture")?, + website: optional_metadata_string(object, "website")?, + nip05: optional_metadata_string(object, "nip05")?, + lud16: optional_metadata_string(object, "lud16")?, + }) +} + +fn optional_metadata_string( + object: &serde_json::Map<String, serde_json::Value>, + field: &'static str, +) -> Result<Option<String>, String> { + match object.get(field) { + Some(value) if value.is_null() => Ok(None), + Some(value) => { + let value = value + .as_str() + .ok_or_else(|| format!("seller profile metadata `{field}` must be a string"))? + .trim(); + if value.is_empty() { + return Ok(None); + } + Ok(Some(value.to_owned())) + } + None => Ok(None), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ListingKind { Public, @@ -2121,16 +2257,17 @@ fn listing_kind_for_event(event: &Event) -> Option<ListingKind> { mod tests { use super::{ 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, - NIP32_LABEL_KIND, NIP56_REPORT_KIND, NIP99_PUBLIC_LISTING_KIND, ReactionValue, - ReportTarget, ReportType, 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_report_event, - parse_required_u64_tag, parse_u64_field, repeated_or_missing_policy_boundary, + ListingKind, ListingProjectionEvaluation, ListingUnit, LongFormKind, NIP01_METADATA_KIND, + NIP7D_THREAD_KIND, NIP22_COMMENT_KIND, NIP23_LONG_FORM_DRAFT_KIND, NIP23_LONG_FORM_KIND, + NIP25_REACTION_KIND, NIP32_LABEL_KIND, NIP56_REPORT_KIND, NIP99_PUBLIC_LISTING_KIND, + ReactionValue, ReportTarget, ReportType, 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_report_event, parse_required_u64_tag, + parse_seller_profile_event, parse_u64_field, repeated_or_missing_policy_boundary, required_tag_value, required_tag_values, single_letter_tag_values, single_letter_values_for, tag_count, }; @@ -3263,6 +3400,113 @@ mod tests { } #[test] + fn seller_profile_parser_extracts_metadata_tags_and_trust_markers() { + let event = event_with_kind_tags_and_content( + u64::from(NIP01_METADATA_KIND), + vec![ + Tag::from_parts("region", &[" Vancouver Island "]).expect("region"), + Tag::from_parts("category", &["Vegetables"]).expect("category"), + Tag::from_parts("category", &[" vegetables "]).expect("category duplicate"), + Tag::from_parts("trust", &["verified-local"]).expect("trust"), + ], + r#"{ + "name": "sunrise-farm", + "display_name": "Sunrise Farm", + "about": "Certified organic farm stand.", + "picture": "https://radroots.test/sunrise.jpg", + "website": "https://sunrise.radroots.test", + "nip05": "sunrise@radroots.test", + "lud16": "sunrise@wallet.radroots.test" + }"#, + ); + + let profile = parse_seller_profile_event(&event) + .expect("parse") + .expect("profile"); + + assert_eq!(profile.event_id(), event.id()); + assert_eq!(profile.pubkey(), event.unsigned().pubkey()); + assert_eq!(profile.created_at(), event.unsigned().created_at()); + assert_eq!(profile.metadata().name(), Some("sunrise-farm")); + assert_eq!(profile.metadata().display_name(), Some("Sunrise Farm")); + assert_eq!( + profile.metadata().about(), + Some("Certified organic farm stand.") + ); + assert_eq!( + profile.metadata().picture(), + Some("https://radroots.test/sunrise.jpg") + ); + assert_eq!( + profile.metadata().website(), + Some("https://sunrise.radroots.test") + ); + assert_eq!(profile.metadata().nip05(), Some("sunrise@radroots.test")); + assert_eq!( + profile.metadata().lud16(), + Some("sunrise@wallet.radroots.test") + ); + assert_eq!(profile.regions(), &["vancouver island".to_owned()]); + assert_eq!(profile.categories(), &["vegetables".to_owned()]); + assert_eq!(profile.trust_markers(), &["verified-local".to_owned()]); + } + + #[test] + fn seller_profile_parser_ignores_other_kinds_and_trims_empty_metadata() { + let profile = event_with_kind_tags_and_content( + u64::from(NIP01_METADATA_KIND), + Vec::new(), + r#"{"name":" ","display_name":null,"about":" Market seller. "}"#, + ); + let note = event_with_kind_tags_and_content(1, Vec::new(), r#"{"name":"not-a-profile"}"#); + + let profile = parse_seller_profile_event(&profile) + .expect("parse") + .expect("profile"); + + assert_eq!(profile.metadata().name(), None); + assert_eq!(profile.metadata().display_name(), None); + assert_eq!(profile.metadata().about(), Some("Market seller.")); + assert_eq!(parse_seller_profile_event(&note), Ok(None)); + } + + #[test] + fn seller_profile_parser_rejects_bad_metadata_and_tags() { + let invalid_json = + event_with_kind_tags_and_content(u64::from(NIP01_METADATA_KIND), Vec::new(), "{"); + let non_object = + event_with_kind_tags_and_content(u64::from(NIP01_METADATA_KIND), Vec::new(), "[]"); + let non_string = event_with_kind_tags_and_content( + u64::from(NIP01_METADATA_KIND), + Vec::new(), + r#"{"name":1}"#, + ); + let empty_region = event_with_kind_tags_and_content( + u64::from(NIP01_METADATA_KIND), + vec![Tag::from_parts("region", &[" "]).expect("region")], + "{}", + ); + + assert!( + parse_seller_profile_event(&invalid_json) + .expect_err("invalid json") + .contains("seller profile metadata JSON is invalid") + ); + assert_eq!( + parse_seller_profile_event(&non_object).expect_err("non object"), + "seller profile metadata must be a JSON object" + ); + assert_eq!( + parse_seller_profile_event(&non_string).expect_err("non string"), + "seller profile metadata `name` must be a string" + ); + assert_eq!( + parse_seller_profile_event(&empty_region).expect_err("empty region"), + "seller profile region value must not be empty" + ); + } + + #[test] fn listing_identity_parser_extracts_public_listing_address() { let event = event_with_kind_and_tags( u64::from(NIP99_PUBLIC_LISTING_KIND),