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:
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;