lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 47fce218ed79eb823075be74e2abddd112c3f0f8
parent 87d778705b1a33798f17f8f68bcd320ac7981ddb
Author: triesap <tyson@radroots.org>
Date:   Sun, 21 Dec 2025 23:58:15 +0000

events-codec: expand codecs and add coverage


- add shared wire/event_ref helpers and new event codecs
- add job/profile decode support and tighten tag handling
- add production-grade integration tests for codecs and utilities
- update nostr/net-core adapters for event metadata paths
- tests not run (not requested)

Diffstat:
Aevents-codec/src/comment/decode.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/comment/encode.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/comment/mod.rs | 2++
Aevents-codec/src/error.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/event_ref.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/follow/decode.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/follow/encode.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/follow/mod.rs | 2++
Mevents-codec/src/job/encode.rs | 44++++----------------------------------------
Mevents-codec/src/job/feedback/decode.rs | 10+++++-----
Mevents-codec/src/job/feedback/encode.rs | 25+++++++++++++++++--------
Aevents-codec/src/job/feedback/mod.rs | 2++
Mevents-codec/src/job/mod.rs | 19+++----------------
Mevents-codec/src/job/request/decode.rs | 8++++----
Mevents-codec/src/job/request/encode.rs | 22++++++++++++++++++----
Aevents-codec/src/job/request/mod.rs | 2++
Mevents-codec/src/job/result/decode.rs | 8++++----
Mevents-codec/src/job/result/encode.rs | 23++++++++++++++++++-----
Aevents-codec/src/job/result/mod.rs | 2++
Mevents-codec/src/job/util.rs | 32++++++++++++++++++++------------
Mevents-codec/src/lib.rs | 15+++++++++++++++
Aevents-codec/src/listing/decode.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/listing/encode.rs | 34++++++++++++++++++++++++++++++++++
Aevents-codec/src/listing/mod.rs | 2++
Aevents-codec/src/post/decode.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/post/encode.rs | 27+++++++++++++++++++++++++++
Aevents-codec/src/post/mod.rs | 2++
Aevents-codec/src/profile/decode.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents-codec/src/profile/encode.rs | 7+++++--
Mevents-codec/src/profile/error.rs | 3+++
Mevents-codec/src/profile/mod.rs | 3+++
Aevents-codec/src/reaction/decode.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/reaction/encode.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/reaction/mod.rs | 2++
Aevents-codec/src/relay_document/decode.rs | 9+++++++++
Aevents-codec/src/relay_document/encode.rs | 12++++++++++++
Aevents-codec/src/relay_document/mod.rs | 2++
Aevents-codec/src/wire.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/comment.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/common/mod.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/event_ref.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/follow.rs | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/job_feedback.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/job_request.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/job_result.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/job_traits.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/job_util.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/listing.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/post.rs | 41+++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/profile.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/profile_encode.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/reaction.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/tests/relay_document.rs | 22++++++++++++++++++++++
Aevents-codec/tests/wire.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Mnet-core/src/nostr_client/events/post.rs | 2+-
Mnet-core/src/nostr_client/events/profile.rs | 2+-
Mnet-core/src/nostr_client/inner.rs | 2+-
Mnet-core/src/nostr_client/manager.rs | 2+-
Mnostr/src/event_adapters.rs | 4++--
Mnostr/src/events/post.rs | 2+-
Mnostr/src/nip11.rs | 2+-
61 files changed, 2227 insertions(+), 108 deletions(-)

diff --git a/events-codec/src/comment/decode.rs b/events-codec/src/comment/decode.rs @@ -0,0 +1,93 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + comment::{RadrootsComment, RadrootsCommentEventIndex, RadrootsCommentEventMetadata}, + tags::{TAG_E_PREV, TAG_E_ROOT}, +}; + +use crate::error::EventParseError; +use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag}; + +const DEFAULT_KIND: u32 = 1; + +pub fn comment_from_tags( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsComment, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "1", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidTag("content")); + } + + let root_tag = find_event_ref_tag(tags, TAG_E_ROOT) + .ok_or(EventParseError::MissingTag(TAG_E_ROOT))?; + let root = parse_event_ref_tag(root_tag, TAG_E_ROOT)?; + + let parent = match find_event_ref_tag(tags, TAG_E_PREV) { + Some(tag) => parse_event_ref_tag(tag, TAG_E_PREV)?, + None => root.clone(), + }; + + Ok(RadrootsComment { + root, + parent, + content: content.to_string(), + }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsCommentEventMetadata, EventParseError> { + let comment = comment_from_tags(kind, &tags, &content)?; + Ok(RadrootsCommentEventMetadata { + id, + author, + published_at, + kind, + comment, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsCommentEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsCommentEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/comment/encode.rs b/events-codec/src/comment/encode.rs @@ -0,0 +1,57 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::{ + comment::RadrootsComment, + tags::{TAG_E_PREV, TAG_E_ROOT}, + RadrootsNostrEventRef, +}; + +use crate::error::EventEncodeError; +use crate::event_ref::build_event_ref_tag; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = 1; + +fn validate_ref( + event: &RadrootsNostrEventRef, + id_label: &'static str, + author_label: &'static str, +) -> Result<(), EventEncodeError> { + if event.id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField(id_label)); + } + if event.author.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField(author_label)); + } + Ok(()) +} + +pub fn comment_build_tags(comment: &RadrootsComment) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_ref(&comment.root, "root.id", "root.author")?; + validate_ref(&comment.parent, "parent.id", "parent.author")?; + + let mut tags = Vec::with_capacity(2); + tags.push(build_event_ref_tag(TAG_E_ROOT, &comment.root)); + tags.push(build_event_ref_tag(TAG_E_PREV, &comment.parent)); + Ok(tags) +} + +pub fn to_wire_parts(comment: &RadrootsComment) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(comment, DEFAULT_KIND) +} + +pub fn to_wire_parts_with_kind( + comment: &RadrootsComment, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if comment.content.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("content")); + } + let tags = comment_build_tags(comment)?; + Ok(WireEventParts { + kind, + content: comment.content.clone(), + tags, + }) +} diff --git a/events-codec/src/comment/mod.rs b/events-codec/src/comment/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/events-codec/src/error.rs b/events-codec/src/error.rs @@ -0,0 +1,56 @@ +use core::fmt; + +#[derive(Debug)] +pub enum EventParseError { + MissingTag(&'static str), + InvalidTag(&'static str), + InvalidKind { expected: &'static str, got: u32 }, + InvalidNumber(&'static str, core::num::ParseIntError), + InvalidJson(&'static str), +} + +impl fmt::Display for EventParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EventParseError::MissingTag(t) => write!(f, "missing tag: {}", t), + EventParseError::InvalidTag(t) => write!(f, "invalid tag structure for '{}'", t), + EventParseError::InvalidKind { expected, got } => { + write!(f, "invalid kind {} (expected {})", got, expected) + } + EventParseError::InvalidNumber(t, e) => write!(f, "invalid number in '{}': {}", t, e), + EventParseError::InvalidJson(ctx) => write!(f, "invalid JSON in '{}'", ctx), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for EventParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + EventParseError::InvalidNumber(_, e) => Some(e), + _ => None, + } + } +} + +#[derive(Debug)] +pub enum EventEncodeError { + InvalidKind(u32), + EmptyRequiredField(&'static str), + Json, +} + +impl fmt::Display for EventEncodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EventEncodeError::InvalidKind(kind) => write!(f, "invalid event kind: {}", kind), + EventEncodeError::EmptyRequiredField(field) => { + write!(f, "empty required field: {}", field) + } + EventEncodeError::Json => write!(f, "failed to serialize JSON"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for EventEncodeError {} diff --git a/events-codec/src/event_ref.rs b/events-codec/src/event_ref.rs @@ -0,0 +1,68 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::RadrootsNostrEventRef; + +use crate::error::EventParseError; + +fn looks_like_relay_url(s: &str) -> bool { + s.starts_with("ws://") || s.starts_with("wss://") +} + +pub fn build_event_ref_tag(tag: &str, event: &RadrootsNostrEventRef) -> Vec<String> { + let relays_len = event.relays.as_ref().map(|r| r.len()).unwrap_or(0); + let mut out = Vec::with_capacity(5 + relays_len); + out.push(tag.to_string()); + out.push(event.id.clone()); + out.push(event.author.clone()); + out.push(event.kind.to_string()); + out.push(event.d_tag.clone().unwrap_or_default()); + if let Some(relays) = &event.relays { + out.extend(relays.iter().cloned()); + } + out +} + +pub fn parse_event_ref_tag( + tag: &[String], + tag_name: &'static str, +) -> Result<RadrootsNostrEventRef, EventParseError> { + if tag.get(0).map(|s| s.as_str()) != Some(tag_name) { + return Err(EventParseError::InvalidTag(tag_name)); + } + let id = tag.get(1).ok_or(EventParseError::InvalidTag(tag_name))?; + let author = tag.get(2).ok_or(EventParseError::InvalidTag(tag_name))?; + let kind_s = tag.get(3).ok_or(EventParseError::InvalidTag(tag_name))?; + let kind: u32 = kind_s + .parse() + .map_err(|e| EventParseError::InvalidNumber(tag_name, e))?; + + let (d_tag, relays_start) = match tag.get(4) { + Some(v) if tag.len() == 5 && looks_like_relay_url(v) => (None, 4), + Some(v) if v.is_empty() => (None, 5), + Some(v) => (Some(v.clone()), 5), + None => (None, 4), + }; + + let relays = if tag.len() > relays_start { + Some(tag[relays_start..].to_vec()) + } else { + None + }; + + Ok(RadrootsNostrEventRef { + id: id.clone(), + author: author.clone(), + kind, + d_tag, + relays, + }) +} + +pub fn find_event_ref_tag<'a>( + tags: &'a [Vec<String>], + tag_name: &'static str, +) -> Option<&'a Vec<String>> { + tags.iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some(tag_name)) +} diff --git a/events-codec/src/follow/decode.rs b/events-codec/src/follow/decode.rs @@ -0,0 +1,103 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + follow::{RadrootsFollow, RadrootsFollowEventIndex, RadrootsFollowEventMetadata, RadrootsFollowProfile}, +}; + +use crate::error::EventParseError; + +const DEFAULT_KIND: u32 = 3; + +fn parse_follow_tag( + tag: &[String], + published_at: u32, +) -> Result<RadrootsFollowProfile, EventParseError> { + if tag.get(0).map(|s| s.as_str()) != Some("p") { + return Err(EventParseError::InvalidTag("p")); + } + let public_key = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?; + let relay_url = tag.get(2).filter(|s| !s.is_empty()).cloned(); + let contact_name = tag.get(3).filter(|s| !s.is_empty()).cloned(); + let published_at = match tag.get(4) { + Some(v) => v + .parse() + .map_err(|e| EventParseError::InvalidNumber("p", e))?, + None => published_at, + }; + + Ok(RadrootsFollowProfile { + published_at, + public_key: public_key.clone(), + relay_url, + contact_name, + }) +} + +pub fn follow_from_tags( + kind: u32, + tags: &[Vec<String>], + published_at: u32, +) -> Result<RadrootsFollow, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "3", + got: kind, + }); + } + let mut list = Vec::new(); + for tag in tags.iter().filter(|t| t.get(0).map(|s| s.as_str()) == Some("p")) { + list.push(parse_follow_tag(tag, published_at)?); + } + Ok(RadrootsFollow { list }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + _content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsFollowEventMetadata, EventParseError> { + let follow = follow_from_tags(kind, &tags, published_at)?; + Ok(RadrootsFollowEventMetadata { + id, + author, + published_at, + kind, + follow, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsFollowEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsFollowEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/follow/encode.rs b/events-codec/src/follow/encode.rs @@ -0,0 +1,46 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::follow::{RadrootsFollow, RadrootsFollowProfile}; + +use crate::error::EventEncodeError; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = 3; + +fn follow_tag(profile: &RadrootsFollowProfile) -> Result<Vec<String>, EventEncodeError> { + if profile.public_key.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("follow.public_key")); + } + let mut tag = Vec::with_capacity(5); + tag.push("p".to_string()); + tag.push(profile.public_key.clone()); + tag.push(profile.relay_url.clone().unwrap_or_default()); + tag.push(profile.contact_name.clone().unwrap_or_default()); + tag.push(profile.published_at.to_string()); + Ok(tag) +} + +pub fn follow_build_tags(follow: &RadrootsFollow) -> Result<Vec<Vec<String>>, EventEncodeError> { + let mut tags = Vec::with_capacity(follow.list.len()); + for profile in &follow.list { + tags.push(follow_tag(profile)?); + } + Ok(tags) +} + +pub fn to_wire_parts(follow: &RadrootsFollow) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(follow, DEFAULT_KIND) +} + +pub fn to_wire_parts_with_kind( + follow: &RadrootsFollow, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + let tags = follow_build_tags(follow)?; + Ok(WireEventParts { + kind, + content: String::new(), + tags, + }) +} diff --git a/events-codec/src/follow/mod.rs b/events-codec/src/follow/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/events-codec/src/job/encode.rs b/events-codec/src/job/encode.rs @@ -1,11 +1,9 @@ use core::fmt; -#[derive(Debug, Clone)] -pub struct WireEventParts { - pub kind: u32, - pub content: String, - pub tags: Vec<Vec<String>>, -} +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +pub use crate::wire::{canonicalize_tags, empty_content, to_draft, EventDraft, WireEventParts}; #[derive(Debug)] pub enum JobEncodeError { @@ -29,45 +27,11 @@ impl fmt::Display for JobEncodeError { #[cfg(feature = "std")] impl std::error::Error for JobEncodeError {} -pub fn canonicalize_tags(tags: &mut Vec<Vec<String>>) { - tags.retain(|t| t.first().map(|s| !s.trim().is_empty()).unwrap_or(false)); - for t in tags.iter_mut() { - for s in t.iter_mut() { - *s = s.trim().to_string(); - } - } - tags.sort_by(|a, b| a.first().cmp(&b.first()).then_with(|| a.cmp(b))); - tags.dedup(); -} - -pub fn empty_content() -> String { - String::new() -} - #[cfg(feature = "serde_json")] pub fn json_content<T: serde::Serialize>(value: &T) -> Result<String, JobEncodeError> { serde_json::to_string(value).map_err(|_| JobEncodeError::EmptyRequiredField("content-json")) } -#[derive(Debug, Clone)] -pub struct EventDraft { - pub kind: u32, - pub created_at: u32, - pub author: String, - pub content: String, - pub tags: Vec<Vec<String>>, -} - -pub fn to_draft(parts: WireEventParts, author: impl Into<String>, created_at: u32) -> EventDraft { - EventDraft { - kind: parts.kind, - created_at, - author: author.into(), - content: parts.content, - tags: parts.tags, - } -} - pub fn push_status_tag(tags: &mut Vec<Vec<String>>, status: &str, extra: Option<&str>) { let mut v = vec!["status".into(), status.into()]; if let Some(e) = extra { diff --git a/events-codec/src/job/feedback/decode.rs b/events-codec/src/job/feedback/decode.rs @@ -4,8 +4,12 @@ use radroots_events::{ job_feedback::{ RadrootsJobFeedback, RadrootsJobFeedbackEventIndex, RadrootsJobFeedbackEventMetadata, }, + kinds::KIND_JOB_FEEDBACK, }; +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + use crate::job::{ error::JobParseError, util::{feedback_status_from_tag, parse_amount_tag_sat, parse_bool_encrypted}, @@ -70,10 +74,6 @@ pub fn job_feedback_from_tags( }) } -fn is_feedback_kind(kind: u32) -> bool { - kind == 7000 -} - pub fn metadata_from_event( id: String, author: String, @@ -82,7 +82,7 @@ pub fn metadata_from_event( content: String, tags: Vec<Vec<String>>, ) -> Result<RadrootsJobFeedbackEventMetadata, JobParseError> { - if !is_feedback_kind(kind) { + if kind != KIND_JOB_FEEDBACK { return Err(JobParseError::InvalidTag("kind (expected 7000)")); } let job_feedback = job_feedback_from_tags(kind, &tags, &content)?; diff --git a/events-codec/src/job/feedback/encode.rs b/events-codec/src/job/feedback/encode.rs @@ -1,21 +1,30 @@ -use radroots_events::job_feedback::RadrootsJobFeedback; +use radroots_events::{job_feedback::RadrootsJobFeedback, kinds::KIND_JOB_FEEDBACK}; use crate::job::encode::{JobEncodeError, WireEventParts, canonicalize_tags}; use crate::job::util::{feedback_status_tag, push_amount_tag_msat}; +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + pub fn job_feedback_build_tags(fb: &RadrootsJobFeedback) -> Vec<Vec<String>> { - let mut tags: Vec<Vec<String>> = Vec::new(); + let mut tags: Vec<Vec<String>> = Vec::with_capacity( + 2 + + usize::from(fb.customer_pubkey.is_some()) + + usize::from(fb.payment.is_some()) + + usize::from(fb.encrypted), + ); - let mut st = vec![ - "status".to_string(), - feedback_status_tag(fb.status).to_string(), - ]; + let mut st = Vec::with_capacity(3); + st.push("status".to_string()); + st.push(feedback_status_tag(fb.status).to_string()); if let Some(info) = &fb.extra_info { st.push(info.clone()); } tags.push(st); - let mut e = vec!["e".to_string(), fb.request_event.id.clone()]; + let mut e = Vec::with_capacity(3); + e.push("e".to_string()); + e.push(fb.request_event.id.clone()); if let Some(r) = &fb.request_event.relays { e.push(r.clone()); } @@ -41,7 +50,7 @@ pub fn to_wire_parts( content: &str, ) -> Result<WireEventParts, JobEncodeError> { let kind = fb.kind as u32; - if kind != 7000 { + if kind != KIND_JOB_FEEDBACK { return Err(JobEncodeError::InvalidKind(kind)); } diff --git a/events-codec/src/job/feedback/mod.rs b/events-codec/src/job/feedback/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/events-codec/src/job/mod.rs b/events-codec/src/job/mod.rs @@ -1,20 +1,7 @@ pub mod encode; pub mod error; pub mod util; - -pub mod feedback { - pub mod decode; - pub mod encode; -} - -pub mod request { - pub mod decode; - pub mod encode; -} - -pub mod result { - pub mod decode; - pub mod encode; -} - +pub mod feedback; +pub mod request; +pub mod result; pub mod traits; diff --git a/events-codec/src/job/request/decode.rs b/events-codec/src/job/request/decode.rs @@ -4,8 +4,12 @@ use radroots_events::{ RadrootsJobInput, RadrootsJobParam, RadrootsJobRequest, RadrootsJobRequestEventIndex, RadrootsJobRequestEventMetadata, }, + kinds::is_request_kind, }; +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + use crate::job::{ error::JobParseError, util::{parse_bid_tag_sat, parse_bool_encrypted, parse_i_tags, parse_params}, @@ -63,10 +67,6 @@ pub fn job_request_from_tags( }) } -fn is_request_kind(kind: u32) -> bool { - (5000..=5999).contains(&kind) -} - pub fn metadata_from_event( id: String, author: String, diff --git a/events-codec/src/job/request/encode.rs b/events-codec/src/job/request/encode.rs @@ -1,13 +1,27 @@ -use radroots_events::job_request::RadrootsJobRequest; +use radroots_events::{job_request::RadrootsJobRequest, kinds::is_request_kind}; use crate::job::encode::{JobEncodeError, WireEventParts, canonicalize_tags}; use crate::job::util::{job_input_type_tag, push_bid_tag_msat}; +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + pub fn job_request_build_tags(req: &RadrootsJobRequest) -> Vec<Vec<String>> { - let mut tags: Vec<Vec<String>> = Vec::new(); + let mut tags: Vec<Vec<String>> = Vec::with_capacity( + req.inputs.len() + + req.params.len() + + req.relays.len() + + req.providers.len() + + req.topics.len() + + usize::from(req.output.is_some()) + + usize::from(req.bid_sat.is_some()) + + usize::from(req.encrypted), + ); for i in &req.inputs { - let mut t = vec!["i".to_string(), i.data.clone()]; + let mut t = Vec::with_capacity(5); + t.push("i".to_string()); + t.push(i.data.clone()); t.push(job_input_type_tag(i.input_type).to_string()); if let Some(relay) = &i.relay { t.push(relay.clone()); @@ -54,7 +68,7 @@ pub fn to_wire_parts( content: &str, ) -> Result<WireEventParts, JobEncodeError> { let kind = req.kind as u32; - if !(5000..=5999).contains(&kind) { + if !is_request_kind(kind) { return Err(JobEncodeError::InvalidKind(kind)); } if req.encrypted && req.providers.is_empty() { diff --git a/events-codec/src/job/request/mod.rs b/events-codec/src/job/request/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/events-codec/src/job/result/decode.rs b/events-codec/src/job/result/decode.rs @@ -3,8 +3,12 @@ use radroots_events::{ job::JobPaymentRequest, job_request::RadrootsJobInput, job_result::{RadrootsJobResult, RadrootsJobResultEventIndex, RadrootsJobResultEventMetadata}, + kinds::is_result_kind, }; +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + use crate::job::{ error::JobParseError, util::{parse_amount_tag_sat, parse_bool_encrypted, parse_i_tags}, @@ -65,10 +69,6 @@ pub fn job_result_from_tags( }) } -fn is_result_kind(kind: u32) -> bool { - (6000..=6999).contains(&kind) -} - pub fn metadata_from_event( id: String, author: String, diff --git a/events-codec/src/job/result/encode.rs b/events-codec/src/job/result/encode.rs @@ -1,14 +1,25 @@ -use radroots_events::job_result::RadrootsJobResult; +use radroots_events::{job_result::RadrootsJobResult, kinds::is_result_kind}; use crate::job::encode::{ JobEncodeError, WireEventParts, assert_no_inputs_when_encrypted, canonicalize_tags, }; use crate::job::util::{job_input_type_tag, push_amount_tag_msat}; +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + pub fn job_result_build_tags(res: &RadrootsJobResult) -> Vec<Vec<String>> { - let mut tags: Vec<Vec<String>> = Vec::new(); + let mut tags: Vec<Vec<String>> = Vec::with_capacity( + 2 + + res.inputs.len() + + usize::from(res.customer_pubkey.is_some()) + + usize::from(res.payment.is_some()) + + usize::from(res.encrypted), + ); - let mut e = vec!["e".to_string(), res.request_event.id.clone()]; + let mut e = Vec::with_capacity(3); + e.push("e".to_string()); + e.push(res.request_event.id.clone()); if let Some(r) = &res.request_event.relays { e.push(r.clone()); } @@ -20,7 +31,9 @@ pub fn job_result_build_tags(res: &RadrootsJobResult) -> Vec<Vec<String>> { if !res.encrypted { for i in &res.inputs { - let mut t = vec!["i".to_string(), i.data.clone()]; + let mut t = Vec::with_capacity(5); + t.push("i".to_string()); + t.push(i.data.clone()); t.push(job_input_type_tag(i.input_type).to_string()); if let Some(relay) = &i.relay { t.push(relay.clone()); @@ -52,7 +65,7 @@ pub fn to_wire_parts( content: &str, ) -> Result<WireEventParts, JobEncodeError> { let kind = res.kind as u32; - if !(6000..=6999).contains(&kind) { + if !is_result_kind(kind) { return Err(JobEncodeError::InvalidKind(kind)); } diff --git a/events-codec/src/job/result/mod.rs b/events-codec/src/job/result/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/events-codec/src/job/util.rs b/events-codec/src/job/util.rs @@ -1,3 +1,6 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + use radroots_events::{ job::{JobFeedbackStatus, JobInputType}, job_request::{RadrootsJobInput, RadrootsJobParam}, @@ -7,23 +10,27 @@ use crate::job::error::JobParseError; fn looks_like_hex_id(s: &str) -> bool { let n = s.len(); - (n == 32 || n == 64) && s.chars().all(|c| c.is_ascii_hexdigit()) + (n == 32 || n == 64) && s.as_bytes().iter().all(|c| c.is_ascii_hexdigit()) +} + +fn starts_with_ignore_ascii_case(s: &str, prefix: &str) -> bool { + s.get(..prefix.len()) + .map(|head| head.eq_ignore_ascii_case(prefix)) + .unwrap_or(false) } fn looks_like_url_or_nostr(s: &str) -> bool { - let ls = s.to_ascii_lowercase(); - ls.starts_with("http://") - || ls.starts_with("https://") - || ls.starts_with("nostr:") - || ls.starts_with("note") - || ls.starts_with("nevent") - || ls.starts_with("naddr") + starts_with_ignore_ascii_case(s, "http://") + || starts_with_ignore_ascii_case(s, "https://") + || starts_with_ignore_ascii_case(s, "nostr:") + || starts_with_ignore_ascii_case(s, "note") + || starts_with_ignore_ascii_case(s, "nevent") + || starts_with_ignore_ascii_case(s, "naddr") || looks_like_hex_id(s) } fn looks_like_ws_relay(s: &str) -> bool { - let ls = s.to_ascii_lowercase(); - ls.starts_with("ws://") || ls.starts_with("wss://") + starts_with_ignore_ascii_case(s, "ws://") || starts_with_ignore_ascii_case(s, "wss://") } pub fn parse_bool_encrypted(tags: &[Vec<String>]) -> bool { @@ -95,8 +102,9 @@ pub fn parse_i_tags(tags: &[Vec<String>]) -> Vec<RadrootsJobInput> { let v = &t[1]; if looks_like_url_or_nostr(v) { data = v.clone(); - let lv = v.to_ascii_lowercase(); - input_type = if lv.starts_with("http://") || lv.starts_with("https://") { + let is_url = starts_with_ignore_ascii_case(v, "http://") + || starts_with_ignore_ascii_case(v, "https://"); + input_type = if is_url { JobInputType::Url } else { JobInputType::Event diff --git a/events-codec/src/lib.rs b/events-codec/src/lib.rs @@ -1,6 +1,21 @@ #![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] #[cfg(not(feature = "std"))] extern crate alloc; +pub mod error; +pub mod event_ref; pub mod job; pub mod profile; +pub mod wire; + +pub mod comment; +pub mod follow; +pub mod post; +pub mod reaction; + +#[cfg(feature = "serde_json")] +pub mod listing; + +#[cfg(feature = "serde_json")] +pub mod relay_document; diff --git a/events-codec/src/listing/decode.rs b/events-codec/src/listing/decode.rs @@ -0,0 +1,105 @@ +#![cfg(feature = "serde_json")] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + listing::{RadrootsListing, RadrootsListingEventIndex, RadrootsListingEventMetadata}, + tags::TAG_D, +}; + +use crate::error::EventParseError; + +const DEFAULT_KIND: u32 = 30402; + +fn parse_d_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { + let tag = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_D)) + .ok_or(EventParseError::MissingTag(TAG_D))?; + let value = tag + .get(1) + .map(|s| s.to_string()) + .ok_or(EventParseError::InvalidTag(TAG_D))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag(TAG_D)); + } + Ok(value) +} + +pub fn listing_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsListing, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "30402", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidJson("content")); + } + let d_tag = parse_d_tag(tags)?; + let mut listing: RadrootsListing = + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; + + if listing.d_tag.trim().is_empty() { + listing.d_tag = d_tag; + } else if listing.d_tag != d_tag { + return Err(EventParseError::InvalidTag(TAG_D)); + } + + Ok(listing) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsListingEventMetadata, EventParseError> { + let listing = listing_from_event(kind, &tags, &content)?; + Ok(RadrootsListingEventMetadata { + id, + author, + published_at, + kind, + listing, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsListingEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsListingEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/listing/encode.rs b/events-codec/src/listing/encode.rs @@ -0,0 +1,34 @@ +#![cfg(feature = "serde_json")] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::{listing::RadrootsListing, tags::TAG_D}; + +use crate::error::EventEncodeError; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = 30402; + +pub fn listing_build_tags(listing: &RadrootsListing) -> Result<Vec<Vec<String>>, EventEncodeError> { + let d_tag = listing.d_tag.trim(); + if d_tag.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("d")); + } + let mut tags = Vec::with_capacity(1); + tags.push(vec![TAG_D.to_string(), d_tag.to_string()]); + Ok(tags) +} + +pub fn to_wire_parts(listing: &RadrootsListing) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(listing, DEFAULT_KIND) +} + +pub fn to_wire_parts_with_kind( + listing: &RadrootsListing, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + let tags = listing_build_tags(listing)?; + let content = serde_json::to_string(listing).map_err(|_| EventEncodeError::Json)?; + Ok(WireEventParts { kind, content, tags }) +} diff --git a/events-codec/src/listing/mod.rs b/events-codec/src/listing/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/events-codec/src/post/decode.rs b/events-codec/src/post/decode.rs @@ -0,0 +1,75 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + post::{RadrootsPost, RadrootsPostEventIndex, RadrootsPostEventMetadata}, +}; + +use crate::error::EventParseError; + +const DEFAULT_KIND: u32 = 1; + +pub fn post_from_content(kind: u32, content: &str) -> Result<RadrootsPost, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "1", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidTag("content")); + } + Ok(RadrootsPost { + content: content.to_string(), + }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + _tags: Vec<Vec<String>>, +) -> Result<RadrootsPostEventMetadata, EventParseError> { + let post = post_from_content(kind, &content)?; + Ok(RadrootsPostEventMetadata { + id, + author, + published_at: published_at as u64, + kind, + post, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsPostEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsPostEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/post/encode.rs b/events-codec/src/post/encode.rs @@ -0,0 +1,27 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::post::RadrootsPost; + +use crate::error::EventEncodeError; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = 1; + +pub fn to_wire_parts(post: &RadrootsPost) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(post, DEFAULT_KIND) +} + +pub fn to_wire_parts_with_kind( + post: &RadrootsPost, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if post.content.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("content")); + } + Ok(WireEventParts { + kind, + content: post.content.clone(), + tags: Vec::new(), + }) +} diff --git a/events-codec/src/post/mod.rs b/events-codec/src/post/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/events-codec/src/profile/decode.rs b/events-codec/src/profile/decode.rs @@ -0,0 +1,106 @@ +#![cfg(feature = "serde_json")] + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + profile::{RadrootsProfile, RadrootsProfileEventIndex, RadrootsProfileEventMetadata}, +}; + +use crate::error::EventParseError; +use serde_json::Value; + +const PROFILE_KIND: u32 = 0; + +fn parse_optional_string(value: &Value, key: &'static str) -> Option<String> { + value.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()) +} + +fn parse_bot(value: &Value) -> Option<String> { + match value.get("bot") { + Some(v) if v.is_string() => v.as_str().map(|s| s.to_string()), + Some(v) if v.is_boolean() => v.as_bool().map(|b| b.to_string()), + _ => None, + } +} + +pub fn profile_from_content(content: &str) -> Result<RadrootsProfile, EventParseError> { + let value: Value = + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; + let obj = value + .as_object() + .ok_or(EventParseError::InvalidJson("content"))?; + let name = obj + .get("name") + .and_then(|v| v.as_str()) + .ok_or(EventParseError::InvalidJson("name"))?; + + Ok(RadrootsProfile { + name: name.to_string(), + display_name: parse_optional_string(&value, "display_name"), + nip05: parse_optional_string(&value, "nip05"), + about: parse_optional_string(&value, "about"), + website: parse_optional_string(&value, "website"), + picture: parse_optional_string(&value, "picture"), + banner: parse_optional_string(&value, "banner"), + lud06: parse_optional_string(&value, "lud06"), + lud16: parse_optional_string(&value, "lud16"), + bot: parse_bot(&value), + }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + _tags: Vec<Vec<String>>, +) -> Result<RadrootsProfileEventMetadata, EventParseError> { + if kind != PROFILE_KIND { + return Err(EventParseError::InvalidKind { + expected: "0", + got: kind, + }); + } + let profile = profile_from_content(&content)?; + Ok(RadrootsProfileEventMetadata { + id, + author, + published_at: published_at as u64, + kind, + profile, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsProfileEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsProfileEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/profile/encode.rs b/events-codec/src/profile/encode.rs @@ -1,11 +1,14 @@ use crate::profile::error::ProfileEncodeError; -use radroots_events::profile::models::RadrootsProfile; +use radroots_events::profile::RadrootsProfile; use nostr::Metadata; use nostr::prelude::Url; #[cfg(feature = "serde_json")] -use crate::job::encode::WireEventParts; +use crate::wire::WireEventParts; + +#[cfg(all(feature = "serde_json", not(feature = "std")))] +use alloc::{string::String, vec::Vec}; pub fn to_metadata(p: &RadrootsProfile) -> Result<Metadata, ProfileEncodeError> { let mut md = Metadata::new().name(p.name.clone()); diff --git a/events-codec/src/profile/error.rs b/events-codec/src/profile/error.rs @@ -1,5 +1,8 @@ use core::fmt; +#[cfg(not(feature = "std"))] +use alloc::string::String; + #[derive(Debug)] pub enum ProfileEncodeError { InvalidUrl(&'static str, String), diff --git a/events-codec/src/profile/mod.rs b/events-codec/src/profile/mod.rs @@ -5,3 +5,6 @@ pub mod error; #[cfg(feature = "nostr")] pub mod encode; + +#[cfg(feature = "serde_json")] +pub mod decode; diff --git a/events-codec/src/reaction/decode.rs b/events-codec/src/reaction/decode.rs @@ -0,0 +1,85 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + reaction::{RadrootsReaction, RadrootsReactionEventIndex, RadrootsReactionEventMetadata}, + tags::TAG_E_ROOT, +}; + +use crate::error::EventParseError; +use crate::event_ref::{find_event_ref_tag, parse_event_ref_tag}; + +const DEFAULT_KIND: u32 = 7; + +pub fn reaction_from_tags( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsReaction, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "7", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidTag("content")); + } + let root_tag = find_event_ref_tag(tags, TAG_E_ROOT) + .ok_or(EventParseError::MissingTag(TAG_E_ROOT))?; + let root = parse_event_ref_tag(root_tag, TAG_E_ROOT)?; + Ok(RadrootsReaction { + root, + content: content.to_string(), + }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsReactionEventMetadata, EventParseError> { + let reaction = reaction_from_tags(kind, &tags, &content)?; + Ok(RadrootsReactionEventMetadata { + id, + author, + published_at, + kind, + reaction, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsReactionEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsReactionEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/reaction/encode.rs b/events-codec/src/reaction/encode.rs @@ -0,0 +1,52 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +use radroots_events::{ + reaction::RadrootsReaction, + tags::TAG_E_ROOT, + RadrootsNostrEventRef, +}; + +use crate::error::EventEncodeError; +use crate::event_ref::build_event_ref_tag; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = 7; + +fn validate_ref(event: &RadrootsNostrEventRef) -> Result<(), EventEncodeError> { + if event.id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("root.id")); + } + if event.author.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("root.author")); + } + Ok(()) +} + +pub fn reaction_build_tags( + reaction: &RadrootsReaction, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_ref(&reaction.root)?; + let mut tags = Vec::with_capacity(1); + tags.push(build_event_ref_tag(TAG_E_ROOT, &reaction.root)); + Ok(tags) +} + +pub fn to_wire_parts(reaction: &RadrootsReaction) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(reaction, DEFAULT_KIND) +} + +pub fn to_wire_parts_with_kind( + reaction: &RadrootsReaction, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if reaction.content.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("content")); + } + let tags = reaction_build_tags(reaction)?; + Ok(WireEventParts { + kind, + content: reaction.content.clone(), + tags, + }) +} diff --git a/events-codec/src/reaction/mod.rs b/events-codec/src/reaction/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/events-codec/src/relay_document/decode.rs b/events-codec/src/relay_document/decode.rs @@ -0,0 +1,9 @@ +#![cfg(feature = "serde_json")] + +use radroots_events::relay_document::RadrootsRelayDocument; + +use crate::error::EventParseError; + +pub fn from_json(content: &str) -> Result<RadrootsRelayDocument, EventParseError> { + serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("relay_document")) +} diff --git a/events-codec/src/relay_document/encode.rs b/events-codec/src/relay_document/encode.rs @@ -0,0 +1,12 @@ +#![cfg(feature = "serde_json")] + +#[cfg(not(feature = "std"))] +use alloc::string::String; + +use radroots_events::relay_document::RadrootsRelayDocument; + +use crate::error::EventEncodeError; + +pub fn to_json(doc: &RadrootsRelayDocument) -> Result<String, EventEncodeError> { + serde_json::to_string(doc).map_err(|_| EventEncodeError::Json) +} diff --git a/events-codec/src/relay_document/mod.rs b/events-codec/src/relay_document/mod.rs @@ -0,0 +1,2 @@ +pub mod decode; +pub mod encode; diff --git a/events-codec/src/wire.rs b/events-codec/src/wire.rs @@ -0,0 +1,46 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +#[derive(Debug, Clone)] +pub struct WireEventParts { + pub kind: u32, + pub content: String, + pub tags: Vec<Vec<String>>, +} + +#[derive(Debug, Clone)] +pub struct EventDraft { + pub kind: u32, + pub created_at: u32, + pub author: String, + pub content: String, + pub tags: Vec<Vec<String>>, +} + +pub fn to_draft(parts: WireEventParts, author: impl Into<String>, created_at: u32) -> EventDraft { + EventDraft { + kind: parts.kind, + created_at, + author: author.into(), + content: parts.content, + tags: parts.tags, + } +} + +pub fn canonicalize_tags(tags: &mut Vec<Vec<String>>) { + tags.retain(|t| t.first().map(|s| !s.trim().is_empty()).unwrap_or(false)); + for t in tags.iter_mut() { + for s in t.iter_mut() { + let trimmed = s.trim(); + if trimmed.len() != s.len() { + *s = trimmed.to_string(); + } + } + } + tags.sort_by(|a, b| a.first().cmp(&b.first()).then_with(|| a.cmp(b))); + tags.dedup(); +} + +pub fn empty_content() -> String { + String::new() +} diff --git a/events-codec/tests/comment.rs b/events-codec/tests/comment.rs @@ -0,0 +1,111 @@ +mod common; + +use radroots_events::comment::RadrootsComment; +use radroots_events::tags::{TAG_E_PREV, TAG_E_ROOT}; + +use radroots_events_codec::comment::decode::comment_from_tags; +use radroots_events_codec::comment::encode::{comment_build_tags, to_wire_parts}; +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::event_ref::build_event_ref_tag; + +fn assert_event_ref_fields( + actual: &radroots_events::RadrootsNostrEventRef, + expected: &radroots_events::RadrootsNostrEventRef, +) { + assert_eq!(actual.id, expected.id); + assert_eq!(actual.author, expected.author); + assert_eq!(actual.kind, expected.kind); + assert_eq!(actual.d_tag, expected.d_tag); + assert_eq!(actual.relays, expected.relays); +} + +#[test] +fn comment_build_tags_requires_root_id() { + let comment = RadrootsComment { + root: common::event_ref("", "author", 1), + parent: common::event_ref("parent", "author", 1), + content: "hello".to_string(), + }; + + let err = comment_build_tags(&comment).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("root.id") + )); +} + +#[test] +fn comment_build_tags_requires_parent_author() { + let comment = RadrootsComment { + root: common::event_ref("root", "author", 1), + parent: common::event_ref("parent", "", 1), + content: "hello".to_string(), + }; + + let err = comment_build_tags(&comment).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("parent.author") + )); +} + +#[test] +fn comment_to_wire_parts_requires_content() { + let comment = RadrootsComment { + root: common::event_ref("root", "author", 1), + parent: common::event_ref("parent", "author", 1), + content: " ".to_string(), + }; + + let err = to_wire_parts(&comment).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("content") + )); +} + +#[test] +fn comment_roundtrip_from_tags_with_parent() { + let root = common::event_ref("root", "author", 1); + let parent = common::event_ref("parent", "author", 1); + + let tags = vec![ + build_event_ref_tag(TAG_E_ROOT, &root), + build_event_ref_tag(TAG_E_PREV, &parent), + ]; + + let comment = comment_from_tags(1, &tags, "hello").unwrap(); + + assert_event_ref_fields(&comment.root, &root); + assert_event_ref_fields(&comment.parent, &parent); + assert_eq!(comment.content, "hello"); +} + +#[test] +fn comment_from_tags_defaults_parent_to_root() { + let root = common::event_ref("root", "author", 1); + let tags = vec![build_event_ref_tag(TAG_E_ROOT, &root)]; + + let comment = comment_from_tags(1, &tags, "hello").unwrap(); + + assert_event_ref_fields(&comment.root, &root); + assert_event_ref_fields(&comment.parent, &root); +} + +#[test] +fn comment_from_tags_requires_root_tag() { + let tags = vec![vec!["p".to_string(), "x".to_string()]]; + + let err = comment_from_tags(1, &tags, "hello").unwrap_err(); + assert!(matches!(err, EventParseError::MissingTag(TAG_E_ROOT))); +} + +#[test] +fn comment_from_tags_rejects_wrong_kind() { + let tags = vec![vec!["e".to_string(), "x".to_string()]]; + let err = comment_from_tags(2, &tags, "hello").unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { expected: "1", got: 2 } + )); +} diff --git a/events-codec/tests/common/mod.rs b/events-codec/tests/common/mod.rs @@ -0,0 +1,48 @@ +#![allow(dead_code)] + +use radroots_events::{RadrootsNostrEvent, RadrootsNostrEventPtr, RadrootsNostrEventRef}; + +pub fn event_ref(id: &str, author: &str, kind: u32) -> RadrootsNostrEventRef { + RadrootsNostrEventRef { + id: id.to_string(), + author: author.to_string(), + kind, + d_tag: None, + relays: None, + } +} + +pub fn event_ref_with_d( + id: &str, + author: &str, + kind: u32, + d_tag: &str, + relays: Option<Vec<String>>, +) -> RadrootsNostrEventRef { + RadrootsNostrEventRef { + id: id.to_string(), + author: author.to_string(), + kind, + d_tag: Some(d_tag.to_string()), + relays, + } +} + +pub fn event_ptr(id: &str, relays: Option<&str>) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: id.to_string(), + relays: relays.map(|s| s.to_string()), + } +} + +pub fn nostr_event(kind: u32, content: &str, tags: Vec<Vec<String>>) -> RadrootsNostrEvent { + RadrootsNostrEvent { + id: "id".to_string(), + author: "author".to_string(), + created_at: 123, + kind, + tags, + content: content.to_string(), + sig: "sig".to_string(), + } +} diff --git a/events-codec/tests/event_ref.rs b/events-codec/tests/event_ref.rs @@ -0,0 +1,81 @@ +mod common; + +use radroots_events_codec::error::EventParseError; +use radroots_events_codec::event_ref::{build_event_ref_tag, find_event_ref_tag, parse_event_ref_tag}; + +#[test] +fn build_and_parse_roundtrip_with_d_tag_and_relays() { + let event = common::event_ref_with_d( + "id", + "author", + 42, + "d-tag", + Some(vec!["wss://relay".to_string()]), + ); + + let tag = build_event_ref_tag("e", &event); + let parsed = parse_event_ref_tag(&tag, "e").unwrap(); + + assert_eq!(parsed.id, event.id); + assert_eq!(parsed.author, event.author); + assert_eq!(parsed.kind, event.kind); + assert_eq!(parsed.d_tag, event.d_tag); + assert_eq!(parsed.relays, event.relays); +} + +#[test] +fn build_and_parse_roundtrip_without_d_tag_or_relays() { + let event = common::event_ref("id", "author", 1); + let tag = build_event_ref_tag("e", &event); + + assert_eq!(tag.len(), 5); + assert_eq!(tag[4], ""); + + let parsed = parse_event_ref_tag(&tag, "e").unwrap(); + assert_eq!(parsed.id, event.id); + assert_eq!(parsed.author, event.author); + assert_eq!(parsed.kind, event.kind); + assert!(parsed.d_tag.is_none()); + assert!(parsed.relays.is_none()); +} + +#[test] +fn parse_event_ref_tag_allows_relay_only_fifth_entry() { + let tag = vec![ + "e".to_string(), + "id".to_string(), + "author".to_string(), + "1".to_string(), + "wss://relay".to_string(), + ]; + + let parsed = parse_event_ref_tag(&tag, "e").unwrap(); + assert!(parsed.d_tag.is_none()); + assert_eq!(parsed.relays, Some(vec!["wss://relay".to_string()])); +} + +#[test] +fn parse_event_ref_tag_rejects_invalid_kind() { + let tag = vec![ + "e".to_string(), + "id".to_string(), + "author".to_string(), + "bad".to_string(), + ]; + + let err = parse_event_ref_tag(&tag, "e").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidNumber("e", _))); +} + +#[test] +fn find_event_ref_tag_locates_first_match() { + let event = common::event_ref("id", "author", 1); + let tags = vec![ + vec!["p".to_string(), "pubkey".to_string()], + build_event_ref_tag("e", &event), + ]; + + let found = find_event_ref_tag(&tags, "e").unwrap(); + assert_eq!(found[0], "e"); + assert_eq!(found[1], "id"); +} diff --git a/events-codec/tests/follow.rs b/events-codec/tests/follow.rs @@ -0,0 +1,83 @@ +use radroots_events::follow::{RadrootsFollow, RadrootsFollowProfile}; + +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::follow::decode::follow_from_tags; +use radroots_events_codec::follow::encode::to_wire_parts; + +#[test] +fn follow_to_wire_parts_builds_p_tags() { + let follow = RadrootsFollow { + list: vec![RadrootsFollowProfile { + published_at: 42, + public_key: "pubkey".to_string(), + relay_url: Some("wss://relay".to_string()), + contact_name: Some("alice".to_string()), + }], + }; + + let parts = to_wire_parts(&follow).unwrap(); + assert_eq!(parts.kind, 3); + assert_eq!(parts.content, ""); + assert_eq!(parts.tags.len(), 1); + + let tag = &parts.tags[0]; + assert_eq!(tag[0], "p"); + assert_eq!(tag[1], "pubkey"); + assert_eq!(tag[2], "wss://relay"); + assert_eq!(tag[3], "alice"); + assert_eq!(tag[4], "42"); +} + +#[test] +fn follow_to_wire_parts_requires_public_key() { + let follow = RadrootsFollow { + list: vec![RadrootsFollowProfile { + published_at: 1, + public_key: " ".to_string(), + relay_url: None, + contact_name: None, + }], + }; + + let err = to_wire_parts(&follow).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("follow.public_key") + )); +} + +#[test] +fn follow_from_tags_defaults_published_at() { + let tags = vec![vec!["p".to_string(), "pubkey".to_string()]]; + + let follow = follow_from_tags(3, &tags, 123).unwrap(); + assert_eq!(follow.list.len(), 1); + assert_eq!(follow.list[0].published_at, 123); + assert_eq!(follow.list[0].public_key, "pubkey"); + assert!(follow.list[0].relay_url.is_none()); + assert!(follow.list[0].contact_name.is_none()); +} + +#[test] +fn follow_from_tags_uses_tag_published_at() { + let tags = vec![vec![ + "p".to_string(), + "pubkey".to_string(), + "".to_string(), + "".to_string(), + "77".to_string(), + ]]; + + let follow = follow_from_tags(3, &tags, 123).unwrap(); + assert_eq!(follow.list[0].published_at, 77); +} + +#[test] +fn follow_from_tags_rejects_wrong_kind() { + let tags = vec![vec!["p".to_string(), "pubkey".to_string()]]; + let err = follow_from_tags(4, &tags, 123).unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { expected: "3", got: 4 } + )); +} diff --git a/events-codec/tests/job_feedback.rs b/events-codec/tests/job_feedback.rs @@ -0,0 +1,79 @@ +mod common; + +use radroots_events::job::{JobFeedbackStatus, JobPaymentRequest}; +use radroots_events::job_feedback::RadrootsJobFeedback; +use radroots_events::kinds::KIND_JOB_FEEDBACK; +use radroots_events_codec::job::encode::JobEncodeError; +use radroots_events_codec::job::error::JobParseError; +use radroots_events_codec::job::feedback::decode::job_feedback_from_tags; +use radroots_events_codec::job::feedback::encode::to_wire_parts; + +fn sample_feedback() -> RadrootsJobFeedback { + RadrootsJobFeedback { + kind: KIND_JOB_FEEDBACK as u16, + status: JobFeedbackStatus::Processing, + extra_info: Some("queued".to_string()), + request_event: common::event_ptr("req", Some("wss://relay")), + customer_pubkey: Some("customer".to_string()), + payment: Some(JobPaymentRequest { + amount_sat: 12, + bolt11: None, + }), + content: Some("payload".to_string()), + encrypted: false, + } +} + +#[test] +fn job_feedback_roundtrip_from_tags() { + let fb = sample_feedback(); + let content = fb.content.clone().unwrap(); + let parts = to_wire_parts(&fb, &content).unwrap(); + + let decoded = job_feedback_from_tags(parts.kind, &parts.tags, &content).unwrap(); + assert_eq!(decoded, fb); +} + +#[test] +fn job_feedback_requires_valid_kind() { + let mut fb = sample_feedback(); + fb.kind = 7001; + + let err = to_wire_parts(&fb, "payload").unwrap_err(); + assert!(matches!(err, JobEncodeError::InvalidKind(7001))); +} + +#[test] +fn job_feedback_requires_status_tag() { + let tags = vec![vec!["e".to_string(), "req".to_string()]]; + let err = job_feedback_from_tags(KIND_JOB_FEEDBACK, &tags, "payload").unwrap_err(); + assert!(matches!(err, JobParseError::MissingTag("status"))); +} + +#[test] +fn job_feedback_rejects_unknown_status() { + let tags = vec![ + vec!["status".to_string(), "unknown".to_string()], + vec!["e".to_string(), "req".to_string()], + ]; + let err = job_feedback_from_tags(KIND_JOB_FEEDBACK, &tags, "payload").unwrap_err(); + assert!(matches!(err, JobParseError::InvalidTag("status"))); +} + +#[test] +fn job_feedback_metadata_rejects_wrong_kind() { + let err = radroots_events_codec::job::feedback::decode::metadata_from_event( + "id".to_string(), + "author".to_string(), + 1, + 1000, + "payload".to_string(), + Vec::new(), + ) + .unwrap_err(); + + assert!(matches!( + err, + JobParseError::InvalidTag("kind (expected 7000)") + )); +} diff --git a/events-codec/tests/job_request.rs b/events-codec/tests/job_request.rs @@ -0,0 +1,77 @@ +use radroots_events::job::JobInputType; +use radroots_events::job_request::{RadrootsJobInput, RadrootsJobParam, RadrootsJobRequest}; +use radroots_events_codec::job::encode::JobEncodeError; +use radroots_events_codec::job::error::JobParseError; +use radroots_events_codec::job::request::decode::job_request_from_tags; +use radroots_events_codec::job::request::encode::to_wire_parts; + +fn sample_request() -> RadrootsJobRequest { + RadrootsJobRequest { + kind: 5001, + inputs: vec![RadrootsJobInput { + data: "https://example.com".to_string(), + input_type: JobInputType::Url, + relay: Some("wss://relay".to_string()), + marker: Some("source".to_string()), + }], + output: Some("json".to_string()), + params: vec![RadrootsJobParam { + key: "foo".to_string(), + value: "bar".to_string(), + }], + bid_sat: Some(250), + relays: vec!["wss://relay".to_string()], + providers: vec!["provider".to_string()], + topics: vec!["topic".to_string()], + encrypted: false, + } +} + +#[test] +fn job_request_roundtrip_from_tags() { + let req = sample_request(); + let parts = to_wire_parts(&req, "payload").unwrap(); + + let decoded = job_request_from_tags(parts.kind, &parts.tags).unwrap(); + assert_eq!(decoded, req); +} + +#[test] +fn job_request_requires_valid_kind() { + let mut req = sample_request(); + req.kind = 7000; + + let err = to_wire_parts(&req, "payload").unwrap_err(); + assert!(matches!(err, JobEncodeError::InvalidKind(7000))); +} + +#[test] +fn job_request_requires_providers_when_encrypted() { + let mut req = sample_request(); + req.encrypted = true; + req.providers.clear(); + + let err = to_wire_parts(&req, "payload").unwrap_err(); + assert!(matches!(err, JobEncodeError::MissingProvidersForEncrypted)); + + let tags = vec![vec!["encrypted".to_string()]]; + let err = job_request_from_tags(5001, &tags).unwrap_err(); + assert!(matches!(err, JobParseError::MissingTag("p"))); +} + +#[test] +fn job_request_metadata_rejects_wrong_kind() { + let err = radroots_events_codec::job::request::decode::metadata_from_event( + "id".to_string(), + "author".to_string(), + 1, + 1000, + Vec::new(), + ) + .unwrap_err(); + + assert!(matches!( + err, + JobParseError::InvalidTag("kind (expected 5000-5999)") + )); +} diff --git a/events-codec/tests/job_result.rs b/events-codec/tests/job_result.rs @@ -0,0 +1,74 @@ +mod common; + +use radroots_events::job::{JobInputType, JobPaymentRequest}; +use radroots_events::job_request::RadrootsJobInput; +use radroots_events::job_result::RadrootsJobResult; +use radroots_events_codec::job::encode::JobEncodeError; +use radroots_events_codec::job::error::JobParseError; +use radroots_events_codec::job::result::decode::job_result_from_tags; +use radroots_events_codec::job::result::encode::to_wire_parts; + +fn sample_result() -> RadrootsJobResult { + RadrootsJobResult { + kind: 6001, + request_event: common::event_ptr("req", Some("wss://relay")), + request_json: Some("{\"foo\":\"bar\"}".to_string()), + inputs: vec![RadrootsJobInput { + data: "https://example.com".to_string(), + input_type: JobInputType::Url, + relay: None, + marker: None, + }], + customer_pubkey: Some("customer".to_string()), + payment: Some(JobPaymentRequest { + amount_sat: 50, + bolt11: Some("bolt".to_string()), + }), + content: Some("payload".to_string()), + encrypted: false, + } +} + +#[test] +fn job_result_roundtrip_from_tags() { + let res = sample_result(); + let content = res.content.clone().unwrap(); + let parts = to_wire_parts(&res, &content).unwrap(); + + let decoded = job_result_from_tags(parts.kind, &parts.tags, &content).unwrap(); + assert_eq!(decoded, res); +} + +#[test] +fn job_result_requires_valid_kind() { + let mut res = sample_result(); + res.kind = 5000; + + let err = to_wire_parts(&res, "payload").unwrap_err(); + assert!(matches!(err, JobEncodeError::InvalidKind(5000))); +} + +#[test] +fn job_result_requires_request_event_tag() { + let tags = vec![vec!["p".to_string(), "customer".to_string()]]; + let err = job_result_from_tags(6001, &tags, "payload").unwrap_err(); + assert!(matches!(err, JobParseError::MissingTag("e"))); +} + +#[test] +fn job_result_metadata_rejects_wrong_kind() { + let err = radroots_events_codec::job::result::decode::metadata_from_event( + "id".to_string(), + "author".to_string(), + 1, + 1000, + "payload".to_string(), + Vec::new(), + ) + .unwrap_err(); + + assert!(matches!( + err, + JobParseError::InvalidTag("kind (expected 6000-6999)") + )); +} diff --git a/events-codec/tests/job_traits.rs b/events-codec/tests/job_traits.rs @@ -0,0 +1,52 @@ +use radroots_events::job::JobInputType; +use radroots_events::job_request::{RadrootsJobInput, RadrootsJobParam, RadrootsJobRequest}; +use radroots_events::RadrootsNostrEvent; +use radroots_events_codec::job::request::encode::to_wire_parts; +use radroots_events_codec::job::traits::{BorrowedEventAdapter, JobEventLike}; + +fn sample_request() -> RadrootsJobRequest { + RadrootsJobRequest { + kind: 5001, + inputs: vec![RadrootsJobInput { + data: "hello".to_string(), + input_type: JobInputType::Text, + relay: None, + marker: None, + }], + output: None, + params: vec![RadrootsJobParam { + key: "foo".to_string(), + value: "bar".to_string(), + }], + bid_sat: None, + relays: Vec::new(), + providers: vec!["provider".to_string()], + topics: Vec::new(), + encrypted: false, + } +} + +#[test] +fn borrowed_event_adapter_builds_request_metadata() { + let req = sample_request(); + let parts = to_wire_parts(&req, "payload").unwrap(); + + let event = RadrootsNostrEvent { + id: "id".to_string(), + author: "author".to_string(), + created_at: 42, + kind: parts.kind, + tags: parts.tags.clone(), + content: "payload".to_string(), + sig: "sig".to_string(), + }; + + let adapter = BorrowedEventAdapter::new(&event, event.created_at, &event.tags, &event.sig); + let metadata = adapter.to_job_request_metadata().unwrap(); + + assert_eq!(metadata.id, event.id); + assert_eq!(metadata.author, event.author); + assert_eq!(metadata.published_at, event.created_at); + assert_eq!(metadata.kind, event.kind); + assert_eq!(metadata.job_request, req); +} diff --git a/events-codec/tests/job_util.rs b/events-codec/tests/job_util.rs @@ -0,0 +1,131 @@ +use radroots_events::job::{JobFeedbackStatus, JobInputType}; +use radroots_events_codec::job::error::JobParseError; +use radroots_events_codec::job::util::{ + feedback_status_from_tag, feedback_status_tag, job_input_type_from_tag, job_input_type_tag, + parse_amount_tag_sat, parse_bid_tag_sat, parse_bool_encrypted, parse_i_tags, parse_params, + push_amount_tag_msat, push_bid_tag_msat, +}; + +#[test] +fn parse_bool_encrypted_detects_tag() { + let tags = vec![vec!["encrypted".to_string()]]; + assert!(parse_bool_encrypted(&tags)); + assert!(!parse_bool_encrypted(&[])); +} + +#[test] +fn input_type_tag_roundtrip() { + let t = job_input_type_tag(JobInputType::Url); + assert_eq!(job_input_type_from_tag(t), Some(JobInputType::Url)); + assert_eq!(job_input_type_from_tag("unknown"), None); +} + +#[test] +fn feedback_status_tag_roundtrip() { + let t = feedback_status_tag(JobFeedbackStatus::Processing); + assert_eq!(feedback_status_from_tag(t), Some(JobFeedbackStatus::Processing)); + assert_eq!(feedback_status_from_tag("unknown"), None); +} + +#[test] +fn parse_i_tags_handles_multiple_shapes() { + let tags = vec![ + vec!["i".to_string(), "https://example.com".to_string()], + vec!["i".to_string(), "note1abcdef".to_string()], + vec![ + "i".to_string(), + "job-id".to_string(), + "job".to_string(), + "wss://relay".to_string(), + "marker".to_string(), + ], + ]; + + let inputs = parse_i_tags(&tags); + assert_eq!(inputs.len(), 3); + + assert_eq!(inputs[0].data, "https://example.com"); + assert_eq!(inputs[0].input_type, JobInputType::Url); + assert!(inputs[0].relay.is_none()); + assert!(inputs[0].marker.is_none()); + + assert_eq!(inputs[1].data, "note1abcdef"); + assert_eq!(inputs[1].input_type, JobInputType::Event); + + assert_eq!(inputs[2].data, "job-id"); + assert_eq!(inputs[2].input_type, JobInputType::Job); + assert_eq!(inputs[2].relay.as_deref(), Some("wss://relay")); + assert_eq!(inputs[2].marker.as_deref(), Some("marker")); +} + +#[test] +fn parse_params_extracts_key_value_pairs() { + let tags = vec![ + vec!["param".to_string(), "k".to_string(), "v".to_string()], + vec!["param".to_string(), "skip".to_string()], + ]; + + let params = parse_params(&tags); + assert_eq!(params.len(), 1); + assert_eq!(params[0].key, "k"); + assert_eq!(params[0].value, "v"); +} + +#[test] +fn parse_amount_tag_sat_accepts_msat_and_bolt11() { + let tags = vec![vec![ + "amount".to_string(), + "1000".to_string(), + "bolt11".to_string(), + ]]; + + let parsed = parse_amount_tag_sat(&tags).unwrap().unwrap(); + assert_eq!(parsed.0, 1); + assert_eq!(parsed.1.as_deref(), Some("bolt11")); +} + +#[test] +fn parse_amount_tag_sat_rejects_non_whole_sats() { + let tags = vec![vec!["amount".to_string(), "1500".to_string()]]; + let err = parse_amount_tag_sat(&tags).unwrap_err(); + assert!(matches!(err, JobParseError::NonWholeSats("amount"))); +} + +#[test] +fn parse_amount_tag_sat_rejects_overflow() { + let overflow = ((u32::MAX as u64) + 1) * 1000; + let tags = vec![vec!["amount".to_string(), overflow.to_string()]]; + let err = parse_amount_tag_sat(&tags).unwrap_err(); + assert!(matches!(err, JobParseError::AmountOverflow("amount"))); +} + +#[test] +fn push_amount_tag_msat_writes_msat() { + let mut tags = Vec::new(); + push_amount_tag_msat(&mut tags, 12, Some("bolt".to_string())); + assert_eq!( + tags[0], + vec!["amount".to_string(), "12000".to_string(), "bolt".to_string()] + ); +} + +#[test] +fn parse_bid_tag_sat_accepts_msat() { + let tags = vec![vec!["bid".to_string(), "2000".to_string()]]; + let bid = parse_bid_tag_sat(&tags).unwrap().unwrap(); + assert_eq!(bid, 2); +} + +#[test] +fn parse_bid_tag_sat_rejects_non_whole_sats() { + let tags = vec![vec!["bid".to_string(), "2500".to_string()]]; + let err = parse_bid_tag_sat(&tags).unwrap_err(); + assert!(matches!(err, JobParseError::NonWholeSats("bid"))); +} + +#[test] +fn push_bid_tag_msat_writes_msat() { + let mut tags = Vec::new(); + push_bid_tag_msat(&mut tags, 7); + assert_eq!(tags[0], vec!["bid".to_string(), "7000".to_string()]); +} diff --git a/events-codec/tests/listing.rs b/events-codec/tests/listing.rs @@ -0,0 +1,104 @@ +#![cfg(feature = "serde_json")] + +use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, +}; +use radroots_events::listing::{ + RadrootsListing, RadrootsListingProduct, RadrootsListingQuantity, +}; +use radroots_events::tags::TAG_D; +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::listing::decode::listing_from_event; +use radroots_events_codec::listing::encode::{listing_build_tags, to_wire_parts}; + +fn sample_listing(d_tag: &str) -> RadrootsListing { + let quantity = RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), RadrootsCoreUnit::Each); + let price = RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(10u32), RadrootsCoreCurrency::USD), + quantity.clone(), + ); + + RadrootsListing { + d_tag: d_tag.to_string(), + product: RadrootsListingProduct { + key: "sku".to_string(), + title: "Widget".to_string(), + category: "Tools".to_string(), + summary: None, + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + quantities: vec![RadrootsListingQuantity { + value: quantity, + label: None, + count: Some(1), + }], + prices: vec![price], + discounts: None, + location: None, + images: None, + } +} + +#[test] +fn listing_build_tags_requires_d_tag() { + let listing = sample_listing(""); + let err = listing_build_tags(&listing).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("d") + )); +} + +#[test] +fn listing_roundtrip_from_event() { + let listing = sample_listing("listing-1"); + let parts = to_wire_parts(&listing).unwrap(); + + let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.d_tag, listing.d_tag); + assert_eq!(decoded.product.key, listing.product.key); + assert_eq!(decoded.product.title, listing.product.title); + assert_eq!(decoded.quantities.len(), listing.quantities.len()); + assert_eq!(decoded.prices.len(), listing.prices.len()); +} + +#[test] +fn listing_from_event_fills_missing_d_tag() { + let listing = sample_listing(""); + let content = serde_json::to_string(&listing).unwrap(); + let tags = vec![vec![TAG_D.to_string(), "filled".to_string()]]; + + let decoded = listing_from_event(30402, &tags, &content).unwrap(); + assert_eq!(decoded.d_tag, "filled"); +} + +#[test] +fn listing_from_event_rejects_mismatched_d_tag() { + let listing = sample_listing("a"); + let content = serde_json::to_string(&listing).unwrap(); + let tags = vec![vec![TAG_D.to_string(), "b".to_string()]]; + + let err = listing_from_event(30402, &tags, &content).unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag(TAG_D))); +} + +#[test] +fn listing_from_event_rejects_wrong_kind() { + let listing = sample_listing("listing-1"); + let content = serde_json::to_string(&listing).unwrap(); + let tags = vec![vec![TAG_D.to_string(), "listing-1".to_string()]]; + + let err = listing_from_event(1, &tags, &content).unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "30402", + got: 1 + } + )); +} diff --git a/events-codec/tests/post.rs b/events-codec/tests/post.rs @@ -0,0 +1,41 @@ +use radroots_events::post::RadrootsPost; +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::post::decode::post_from_content; +use radroots_events_codec::post::encode::to_wire_parts; + +#[test] +fn post_to_wire_parts_requires_content() { + let post = RadrootsPost { + content: " ".to_string(), + }; + + let err = to_wire_parts(&post).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("content") + )); +} + +#[test] +fn post_to_wire_parts_sets_kind_and_content() { + let post = RadrootsPost { + content: "hello".to_string(), + }; + + let parts = to_wire_parts(&post).unwrap(); + assert_eq!(parts.kind, 1); + assert_eq!(parts.content, "hello"); + assert!(parts.tags.is_empty()); +} + +#[test] +fn post_from_content_requires_kind_and_content() { + let err = post_from_content(2, "hello").unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { expected: "1", got: 2 } + )); + + let err = post_from_content(1, " ").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("content"))); +} diff --git a/events-codec/tests/profile.rs b/events-codec/tests/profile.rs @@ -0,0 +1,53 @@ +#![cfg(feature = "serde_json")] + +use radroots_events_codec::error::EventParseError; +use radroots_events_codec::profile::decode::profile_from_content; + +#[test] +fn profile_from_content_parses_bot_boolean() { + let content = r#"{"name":"alice","bot":true}"#; + let profile = profile_from_content(content).unwrap(); + + assert_eq!(profile.name, "alice"); + assert_eq!(profile.bot.as_deref(), Some("true")); +} + +#[test] +fn profile_from_content_parses_bot_string() { + let content = r#"{"name":"alice","bot":"false"}"#; + let profile = profile_from_content(content).unwrap(); + + assert_eq!(profile.name, "alice"); + assert_eq!(profile.bot.as_deref(), Some("false")); +} + +#[test] +fn profile_from_content_rejects_missing_name() { + let content = r#"{"display_name":"alice"}"#; + let err = profile_from_content(content).unwrap_err(); + assert!(matches!(err, EventParseError::InvalidJson("name"))); +} + +#[test] +fn profile_from_content_rejects_invalid_json() { + let err = profile_from_content("{").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidJson("content"))); +} + +#[test] +fn profile_metadata_rejects_wrong_kind() { + let err = radroots_events_codec::profile::decode::metadata_from_event( + "id".to_string(), + "author".to_string(), + 1, + 1, + "{\"name\":\"alice\"}".to_string(), + Vec::new(), + ) + .unwrap_err(); + + assert!(matches!( + err, + EventParseError::InvalidKind { expected: "0", got: 1 } + )); +} diff --git a/events-codec/tests/profile_encode.rs b/events-codec/tests/profile_encode.rs @@ -0,0 +1,50 @@ +#![cfg(all(feature = "nostr", feature = "serde_json"))] + +use radroots_events::profile::RadrootsProfile; +use radroots_events_codec::profile::encode::{to_metadata, to_wire_parts}; +use radroots_events_codec::profile::error::ProfileEncodeError; +use serde_json::Value; + +#[test] +fn profile_to_metadata_rejects_invalid_url() { + let profile = RadrootsProfile { + name: "alice".to_string(), + display_name: None, + nip05: None, + about: None, + website: Some("not-a-url".to_string()), + picture: None, + banner: None, + lud06: None, + lud16: None, + bot: None, + }; + + let err = to_metadata(&profile).unwrap_err(); + assert!(matches!( + err, + ProfileEncodeError::InvalidUrl("website", _) + )); +} + +#[test] +fn profile_to_wire_parts_writes_json_content() { + let profile = RadrootsProfile { + name: "alice".to_string(), + display_name: Some("Alice".to_string()), + nip05: None, + about: None, + website: None, + picture: None, + banner: None, + lud06: None, + lud16: None, + bot: None, + }; + + let parts = to_wire_parts(&profile).unwrap(); + assert_eq!(parts.kind, 0); + + let value: Value = serde_json::from_str(&parts.content).unwrap(); + assert_eq!(value.get("name").and_then(|v| v.as_str()), Some("alice")); +} diff --git a/events-codec/tests/reaction.rs b/events-codec/tests/reaction.rs @@ -0,0 +1,57 @@ +mod common; + +use radroots_events::reaction::RadrootsReaction; +use radroots_events::tags::TAG_E_ROOT; + +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::event_ref::build_event_ref_tag; +use radroots_events_codec::reaction::decode::reaction_from_tags; +use radroots_events_codec::reaction::encode::{reaction_build_tags, to_wire_parts}; + +#[test] +fn reaction_build_tags_requires_root_fields() { + let reaction = RadrootsReaction { + root: common::event_ref("", "author", 1), + content: "like".to_string(), + }; + + let err = reaction_build_tags(&reaction).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("root.id") + )); +} + +#[test] +fn reaction_to_wire_parts_requires_content() { + let reaction = RadrootsReaction { + root: common::event_ref("root", "author", 1), + content: " ".to_string(), + }; + + let err = to_wire_parts(&reaction).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("content") + )); +} + +#[test] +fn reaction_from_tags_requires_root_tag() { + let tags = vec![vec!["p".to_string(), "x".to_string()]]; + let err = reaction_from_tags(7, &tags, "+").unwrap_err(); + assert!(matches!(err, EventParseError::MissingTag(TAG_E_ROOT))); +} + +#[test] +fn reaction_roundtrip_from_tags() { + let root = common::event_ref("root", "author", 1); + let tags = vec![build_event_ref_tag(TAG_E_ROOT, &root)]; + + let reaction = reaction_from_tags(7, &tags, "+").unwrap(); + + assert_eq!(reaction.root.id, root.id); + assert_eq!(reaction.root.author, root.author); + assert_eq!(reaction.root.kind, root.kind); + assert_eq!(reaction.content, "+"); +} diff --git a/events-codec/tests/relay_document.rs b/events-codec/tests/relay_document.rs @@ -0,0 +1,22 @@ +#![cfg(feature = "serde_json")] + +use radroots_events_codec::error::EventParseError; +use radroots_events_codec::relay_document::decode::from_json; +use radroots_events_codec::relay_document::encode::to_json; + +#[test] +fn relay_document_roundtrip_json() { + let input = r#"{"name":"relay","supported_nips":[1,2],"software":"radroots"}"#; + let doc = from_json(input).unwrap(); + let output = to_json(&doc).unwrap(); + + let v_in: serde_json::Value = serde_json::from_str(input).unwrap(); + let v_out: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(v_out, v_in); +} + +#[test] +fn relay_document_rejects_invalid_json() { + let err = from_json("{").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidJson("relay_document"))); +} diff --git a/events-codec/tests/wire.rs b/events-codec/tests/wire.rs @@ -0,0 +1,45 @@ +use radroots_events_codec::wire::{canonicalize_tags, empty_content, to_draft, WireEventParts}; + +#[test] +fn canonicalize_tags_trims_sorts_and_dedups() { + let mut tags = vec![ + vec![" z ".to_string(), "b".to_string()], + vec!["t".to_string(), "a".to_string()], + vec!["".to_string(), "x".to_string()], + vec![" t ".to_string(), "a ".to_string()], + vec!["t".to_string(), "a".to_string()], + ]; + + canonicalize_tags(&mut tags); + + assert_eq!( + tags, + vec![ + vec!["t".to_string(), "a".to_string()], + vec!["z".to_string(), "b".to_string()], + ] + ); +} + +#[test] +fn to_draft_copies_fields() { + let parts = WireEventParts { + kind: 42, + content: "hello".to_string(), + tags: vec![vec!["t".to_string(), "a".to_string()]], + }; + + let draft = to_draft(parts, "author", 99); + + assert_eq!(draft.kind, 42); + assert_eq!(draft.created_at, 99); + assert_eq!(draft.author, "author"); + assert_eq!(draft.content, "hello"); + assert_eq!(draft.tags.len(), 1); +} + +#[test] +fn empty_content_is_empty_string() { + let content = empty_content(); + assert!(content.is_empty()); +} diff --git a/net-core/src/nostr_client/events/post.rs b/net-core/src/nostr_client/events/post.rs @@ -1,5 +1,5 @@ use crate::error::{NetError, Result}; -use radroots_events::post::models::RadrootsPostEventMetadata; +use radroots_events::post::RadrootsPostEventMetadata; use crate::nostr_client::manager::NostrClientManager; diff --git a/net-core/src/nostr_client/events/profile.rs b/net-core/src/nostr_client/events/profile.rs @@ -1,5 +1,5 @@ use crate::error::{NetError, Result}; -use radroots_events::profile::models::RadrootsProfileEventMetadata; +use radroots_events::profile::RadrootsProfileEventMetadata; use crate::nostr_client::manager::NostrClientManager; diff --git a/net-core/src/nostr_client/inner.rs b/net-core/src/nostr_client/inner.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use nostr_sdk::prelude::*; -use radroots_events::post::models::RadrootsPostEventMetadata; +use radroots_events::post::RadrootsPostEventMetadata; use tokio::runtime::Handle; use tokio::sync::broadcast; use tokio::task::JoinHandle; diff --git a/net-core/src/nostr_client/manager.rs b/net-core/src/nostr_client/manager.rs @@ -82,7 +82,7 @@ impl NostrClientManager { pub fn subscribe_post_events( &self, - ) -> tokio::sync::broadcast::Receiver<radroots_events::post::models::RadrootsPostEventMetadata> + ) -> tokio::sync::broadcast::Receiver<radroots_events::post::RadrootsPostEventMetadata> { self.inner.post_events_tx.subscribe() } diff --git a/nostr/src/event_adapters.rs b/nostr/src/event_adapters.rs @@ -1,7 +1,7 @@ #[cfg(feature = "events")] -use radroots_events::post::models::{RadrootsPost, RadrootsPostEventMetadata}; +use radroots_events::post::{RadrootsPost, RadrootsPostEventMetadata}; #[cfg(feature = "events")] -use radroots_events::profile::models::{RadrootsProfile, RadrootsProfileEventMetadata}; +use radroots_events::profile::{RadrootsProfile, RadrootsProfileEventMetadata}; #[cfg(feature = "events")] use nostr::event::Event; diff --git a/nostr/src/events/post.rs b/nostr/src/events/post.rs @@ -42,7 +42,7 @@ pub async fn fetch_post_events( client: &Client, limit: u16, since_unix: Option<u64>, -) -> Result<Vec<radroots_events::post::models::RadrootsPostEventMetadata>, NostrUtilsError> { +) -> Result<Vec<radroots_events::post::RadrootsPostEventMetadata>, NostrUtilsError> { let mut filter = Filter::new().kind(Kind::TextNote).limit(limit.into()); if let Some(s) = since_unix { diff --git a/nostr/src/nip11.rs b/nostr/src/nip11.rs @@ -1,5 +1,5 @@ #[cfg(all(feature = "http", feature = "codec"))] -use radroots_events::relay_document::models::RadrootsRelayDocument; +use radroots_events::relay_document::RadrootsRelayDocument; #[cfg(all(feature = "http", feature = "codec"))] use crate::util::ws_to_http;