tangle


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

commit 81d6bd5f47f772095c1c09464c460f76f96927ba
parent 62f7948f6fb29eb87027bb892a82ad72f6f50adf
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 20:17:04 -0700

protocol: add nostr event model

Diffstat:
Mcrates/tangle_protocol/src/lib.rs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 205 insertions(+), 3 deletions(-)

diff --git a/crates/tangle_protocol/src/lib.rs b/crates/tangle_protocol/src/lib.rs @@ -286,6 +286,139 @@ impl fmt::Display for TagValue { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnsignedEvent { + pubkey: PublicKeyHex, + created_at: UnixTimestamp, + kind: Kind, + tags: Vec<Tag>, + content: String, +} + +impl UnsignedEvent { + pub fn new( + pubkey: PublicKeyHex, + created_at: UnixTimestamp, + kind: Kind, + tags: Vec<Tag>, + content: &str, + ) -> Self { + Self { + pubkey, + created_at, + kind, + tags, + content: content.to_owned(), + } + } + + pub fn pubkey(&self) -> &PublicKeyHex { + &self.pubkey + } + + pub fn created_at(&self) -> UnixTimestamp { + self.created_at + } + + pub fn kind(&self) -> Kind { + self.kind + } + + pub fn tags(&self) -> &[Tag] { + &self.tags + } + + pub fn content(&self) -> &str { + &self.content + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Event { + id: EventId, + unsigned: UnsignedEvent, + sig: SignatureHex, +} + +impl Event { + pub fn new(id: EventId, unsigned: UnsignedEvent, sig: SignatureHex) -> Self { + Self { id, unsigned, sig } + } + + pub fn id(&self) -> &EventId { + &self.id + } + + pub fn unsigned(&self) -> &UnsignedEvent { + &self.unsigned + } + + pub fn sig(&self) -> &SignatureHex { + &self.sig + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawEventJson(String); + +impl RawEventJson { + pub fn new(value: &str) -> Result<Self, EventShapeError> { + if value.is_empty() { + return Err(EventShapeError::empty_raw_json()); + } + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +pub struct EventShapeError { + message: String, +} + +impl EventShapeError { + pub fn missing_field(field: &'static str) -> Self { + Self { + message: format!("event field `{field}` is missing"), + } + } + + pub fn invalid_field(field: &'static str, reason: &str) -> Self { + Self { + message: format!("event field `{field}` is invalid: {reason}"), + } + } + + fn empty_raw_json() -> Self { + Self { + message: "raw event JSON must not be empty".to_owned(), + } + } +} + +impl fmt::Display for EventShapeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl fmt::Debug for EventShapeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EventShapeError") + .field("message", &self.message) + .finish() + } +} + +impl std::error::Error for EventShapeError {} + fn require_lowercase_hex(scalar: &'static str, value: &str, expected: usize) -> Result<(), String> { let actual = value.chars().count(); if actual != expected { @@ -323,9 +456,9 @@ fn kind_out_of_range_error(value: u64) -> String { #[cfg(test)] mod tests { use super::{ - EventId, Kind, PublicKeyHex, SignatureHex, SubscriptionId, Tag, TagName, TagValue, - UnixTimestamp, empty_error, invalid_length_error, kind_out_of_range_error, - non_lowercase_hex_error, too_long_error, + Event, EventId, EventShapeError, Kind, PublicKeyHex, RawEventJson, SignatureHex, + SubscriptionId, Tag, TagName, TagValue, UnixTimestamp, UnsignedEvent, empty_error, + invalid_length_error, kind_out_of_range_error, non_lowercase_hex_error, too_long_error, }; use core::str::FromStr; use std::collections::hash_map::DefaultHasher; @@ -502,4 +635,73 @@ mod tests { assert!(!TagName::is_indexable_name("1")); assert_eq!(TagValue::new("").as_str(), ""); } + + #[test] + fn unsigned_event_exposes_nostr_event_shape() { + let pubkey = PublicKeyHex::new(&"1".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"); + let tag = Tag::from_parts("p", &["peer"]).expect("tag"); + let event = UnsignedEvent::new( + pubkey.clone(), + UnixTimestamp::new(1_714_124_433), + Kind::new(1).expect("kind"), + vec![tag.clone()], + "hello", + ); + + assert_eq!(event.pubkey(), &pubkey); + assert_eq!(event.created_at().as_u64(), 1_714_124_433); + assert_eq!(event.kind().as_u32(), 1); + assert_eq!(event.tags(), &[tag]); + assert_eq!(event.content(), "hello"); + assert!(format!("{event:?}").contains("UnsignedEvent")); + } + + #[test] + fn signed_event_wraps_id_unsigned_shape_and_signature() { + let id = EventId::new(&"a".repeat(EventId::HEX_LENGTH)).expect("id"); + let sig = SignatureHex::new(&"b".repeat(SignatureHex::HEX_LENGTH)).expect("sig"); + let unsigned = UnsignedEvent::new( + PublicKeyHex::new(&"c".repeat(PublicKeyHex::HEX_LENGTH)).expect("pubkey"), + UnixTimestamp::new(1), + Kind::new(1).expect("kind"), + Vec::new(), + "", + ); + let event = Event::new(id.clone(), unsigned.clone(), sig.clone()); + + assert_eq!(event.id(), &id); + assert_eq!(event.unsigned(), &unsigned); + assert_eq!(event.sig(), &sig); + assert_eq!(event.clone(), Event::new(id, unsigned, sig)); + assert!(format!("{event:?}").contains("Event")); + } + + #[test] + fn raw_event_json_rejects_empty_input_and_preserves_bytes() { + assert_eq!( + RawEventJson::new("").expect_err("empty").to_string(), + "raw event JSON must not be empty" + ); + let raw = RawEventJson::new("{\"kind\":1}").expect("raw"); + + assert_eq!(raw.as_str(), "{\"kind\":1}"); + assert_eq!(raw.clone().into_string(), "{\"kind\":1}"); + assert_eq!(format!("{raw:?}"), "RawEventJson(\"{\\\"kind\\\":1}\")"); + } + + #[test] + fn event_shape_errors_have_stable_messages() { + let missing = EventShapeError::missing_field("pubkey"); + let invalid = EventShapeError::invalid_field("kind", "must be unsigned integer"); + + assert_eq!(missing.to_string(), "event field `pubkey` is missing"); + assert_eq!( + invalid.to_string(), + "event field `kind` is invalid: must be unsigned integer" + ); + assert_eq!( + format!("{missing:?}"), + "EventShapeError { message: \"event field `pubkey` is missing\" }" + ); + } }