From 819866330c7e2f535a155d1d7efaf2e12dc15dc2 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 4 Dec 2025 15:42:00 +0000 Subject: refactor: split Nip34WritePolicy into focused sub-policies Split the ~900 line Nip34WritePolicy into focused sub-policies for improved testability and maintainability: - AnnouncementPolicy - Repository announcement validation - StatePolicy - State event validation + ref alignment - PrEventPolicy - PR/PR Update validation - RelatedEventPolicy - Forward/backward reference checking The main Nip34WritePolicy now delegates to these sub-policies via a shared PolicyContext that provides domain, database, and git_data_path. Also updates: - README.md: Accurate project structure reflecting actual implementation - docs/learnings: Marks this technical debt item as complete --- src/nostr/policy/related.rs | 276 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 src/nostr/policy/related.rs (limited to 'src/nostr/policy/related.rs') diff --git a/src/nostr/policy/related.rs b/src/nostr/policy/related.rs new file mode 100644 index 0000000..1937ca7 --- /dev/null +++ b/src/nostr/policy/related.rs @@ -0,0 +1,276 @@ +/// Related Event Policy - Forward/backward reference checking +/// +/// Handles validation of events that reference accepted repositories or events +/// (backward references) and events that are referenced by accepted events +/// (forward references). +use nostr_relay_builder::prelude::{ + Alphabet, Event, EventId, Filter, Kind, PublicKey, SingleLetterTag, +}; + +use super::PolicyContext; + +/// Result of reference checking +#[derive(Debug)] +pub enum ReferenceResult { + /// Event references an accepted repository (addressable ref found) + ReferencesRepository(String), + /// Event references an accepted event (event ID found) + ReferencesEvent(EventId), + /// Event is referenced by an accepted event (forward reference) + ReferencedByAccepted, + /// No valid references found - event is an orphan + Orphan, +} + +/// Policy for checking event references (backward and forward) +#[derive(Clone)] +pub struct RelatedEventPolicy { + ctx: PolicyContext, +} + +impl RelatedEventPolicy { + pub fn new(ctx: PolicyContext) -> Self { + Self { ctx } + } + + /// Check all reference types for an event + /// + /// Returns the first valid reference found, or `Orphan` if none found. + pub async fn check_references(&self, event: &Event) -> Result { + // Extract all reference tags from event + let (addressable_refs, event_refs) = Self::extract_reference_tags(event); + + // Check 1: Does this event reference an accepted repository? + if let Some(addr_ref) = self.find_accepted_repository(&addressable_refs).await? { + return Ok(ReferenceResult::ReferencesRepository(addr_ref)); + } + + // Check 2: Does this event reference an accepted event? + if let Some(event_ref) = self.find_accepted_event(&event_refs).await? { + return Ok(ReferenceResult::ReferencesEvent(event_ref)); + } + + // Check 3: Is this event referenced by an accepted event? + if self.is_referenced_by_accepted(event).await? { + return Ok(ReferenceResult::ReferencedByAccepted); + } + + // No valid references found + Ok(ReferenceResult::Orphan) + } + + /// Extract all reference tags from an event (a, A, q, e, E) + /// Returns (addressable_refs, event_refs) + pub fn extract_reference_tags(event: &Event) -> (Vec, Vec) { + let mut addressable_refs = Vec::new(); + let mut event_refs = Vec::new(); + + for tag in event.tags.iter() { + let tag_vec = tag.clone().to_vec(); + if tag_vec.is_empty() { + continue; + } + + match tag_vec[0].as_str() { + // Addressable event references (a, A, q with kind:pubkey:identifier format) + "a" | "A" | "q" if tag_vec.len() > 1 && tag_vec[1].contains(':') => { + addressable_refs.push(tag_vec[1].clone()); + } + // Event ID references (e, E, q with event ID format) + "e" | "E" if tag_vec.len() > 1 => { + if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { + event_refs.push(event_id); + } + } + "q" if tag_vec.len() > 1 && !tag_vec[1].contains(':') => { + if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { + event_refs.push(event_id); + } + } + _ => {} + } + } + + (addressable_refs, event_refs) + } + + /// Check if any addressable events (repositories) exist in database + /// Returns the first matching addressable reference found, or None if none match + async fn find_accepted_repository( + &self, + addressables: &[String], + ) -> Result, String> { + if addressables.is_empty() { + return Ok(None); + } + + // Parse all addressable references + let mut parsed_refs = Vec::new(); + for addr in addressables { + let parts: Vec<&str> = addr.split(':').collect(); + if parts.len() < 3 { + continue; // Skip invalid format + } + + let kind = match parts[0].parse::() { + Ok(k) => k, + Err(_) => continue, // Skip invalid kind + }; + let pubkey = match PublicKey::from_hex(parts[1]) { + Ok(pk) => pk, + Err(_) => continue, // Skip invalid pubkey + }; + let identifier = parts[2].to_string(); + + parsed_refs.push((addr.clone(), kind, pubkey, identifier)); + } + + if parsed_refs.is_empty() { + return Ok(None); + } + + // Group by kind to reduce queries + use std::collections::HashMap; + let mut by_kind: HashMap> = HashMap::new(); + for (addr, kind, pubkey, identifier) in parsed_refs { + by_kind + .entry(kind) + .or_default() + .push((addr, pubkey, identifier)); + } + + // Query each kind group + for (kind, refs) in by_kind { + let authors: Vec = refs.iter().map(|(_, pk, _)| *pk).collect(); + + let filter = Filter::new().kind(Kind::from(kind)).authors(authors); + + match self.ctx.database.query(filter).await { + Ok(events) => { + // Check if any event matches our identifier requirements + for event in events { + for (addr, _pubkey, identifier) in &refs { + // Match identifier tag + if event.tags.iter().any(|tag| { + let tag_vec = tag.clone().to_vec(); + tag_vec.len() >= 2 && tag_vec[0] == "d" && tag_vec[1] == *identifier + }) { + return Ok(Some(addr.clone())); + } + } + } + } + Err(e) => return Err(format!("Database query failed: {}", e)), + } + } + + Ok(None) + } + + /// Check if any events exist in database + /// Returns the first matching event ID found, or None if none match + async fn find_accepted_event( + &self, + event_ids: &[EventId], + ) -> Result, String> { + if event_ids.is_empty() { + return Ok(None); + } + + // Single query for all event IDs + let filter = Filter::new().ids(event_ids.iter().copied()); + + match self.ctx.database.query(filter).await { + Ok(events) => { + // Get first event from the iterator + Ok(events.into_iter().next().map(|e| e.id)) + } + Err(e) => Err(format!("Database query failed: {}", e)), + } + } + + /// Check if any accepted event references this event (forward reference) + /// + /// For regular replaceable events (10000-19999): Checks addressable tags with kind:pubkey format + /// For parameterized replaceable (30000-39999): Checks addressable tags with kind:pubkey:d-identifier format + /// For regular events: Only checks event ID reference tags (e, E, q) + async fn is_referenced_by_accepted(&self, event: &Event) -> Result { + let kind_u16 = event.kind.as_u16(); + + // Check if this is any kind of replaceable event + let is_regular_replaceable = (10000..20000).contains(&kind_u16); + let is_parameterized_replaceable = (30000..40000).contains(&kind_u16); + + if is_regular_replaceable || is_parameterized_replaceable { + // Build the appropriate address format based on event type + let address = if is_parameterized_replaceable { + // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) + let identifier = event + .tags + .iter() + .find_map(|tag| { + let tag_vec = tag.clone().to_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "d" { + Some(tag_vec[1].clone()) + } else { + None + } + }) + .unwrap_or_default(); // Empty string if no 'd' tag + format!( + "{}:{}:{}", + event.kind.as_u16(), + event.pubkey.to_hex(), + identifier + ) + } else { + // For regular replaceable: kind:pubkey format (1 colon) + format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) + }; + + // Check addressable reference tags: a, A, q (with address format) + let addressable_tags = [ + SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference + SingleLetterTag::uppercase(Alphabet::A), // 'A' - uppercase addressable reference + SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote (can be address or ID) + ]; + + for tag_type in &addressable_tags { + let filter = Filter::new().custom_tag(*tag_type, address.clone()); + + match self.ctx.database.query(filter).await { + Ok(events) => { + if !events.is_empty() { + return Ok(true); + } + } + Err(e) => return Err(format!("Database query failed: {}", e)), + } + } + } else { + // For regular events, check event ID reference tags: e, E, q (with hex ID) + let event_id_hex = event.id.to_hex(); + + let event_id_tags = [ + SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference + SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference + SingleLetterTag::lowercase(Alphabet::Q), // 'q' - quote reference + ]; + + for tag_type in &event_id_tags { + let filter = Filter::new().custom_tag(*tag_type, event_id_hex.clone()); + + match self.ctx.database.query(filter).await { + Ok(events) => { + if !events.is_empty() { + return Ok(true); + } + } + Err(e) => return Err(format!("Database query failed: {}", e)), + } + } + } + + Ok(false) + } +} \ No newline at end of file -- cgit v1.2.3