lib

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

commit 4c72c964eed14fafd1cfdbe832d8b3003119878b
parent 09ca67d3d7fa8bb29f7b0b04c3e6da935ea5f348
Author: triesap <tyson@radroots.org>
Date:   Fri, 12 Jun 2026 14:22:01 -0700

events_codec: allow note comments and list sets

- Allow NIP-22 comments to target kind 1 short text notes.
- Decode and encode NIP-51 list-set kinds through the generic list codec.

Diffstat:
Mcrates/events_codec/src/comment/decode.rs | 12+-----------
Mcrates/events_codec/src/comment/encode.rs | 14+-------------
Mcrates/events_codec/src/list/decode.rs | 10+++++++---
Mcrates/events_codec/src/list/encode.rs | 8++++++--
Mcrates/events_codec/tests/comment.rs | 38++++++++++++++++++++++----------------
Mcrates/events_codec/tests/list.rs | 17++++++++++++++---
Mspec/conformance/vectors/social/mvp.v1.json | 16+++++++++++-----
Mspec/social-events.md | 8+++++---
8 files changed, 67 insertions(+), 56 deletions(-)

diff --git a/crates/events_codec/src/comment/decode.rs b/crates/events_codec/src/comment/decode.rs @@ -7,7 +7,7 @@ use alloc::{ use radroots_events::{ RadrootsNostrEvent, comment::RadrootsComment, - kinds::{KIND_COMMENT, KIND_POST}, + kinds::KIND_COMMENT, social::RadrootsSocialTarget, tags::{TAG_E_PREV, TAG_E_ROOT}, }; @@ -109,7 +109,6 @@ fn parse_comment_target( .ok_or(EventParseError::InvalidTag(keys.event))?; validate_lowercase_hex_64_tag(&id, keys.event)?; let kind = required_numeric_kind(tags, keys.kind)?; - validate_comment_target_kind(kind, keys.kind)?; let author = required_author(tags, keys.author)?; let relays = if tag.len() > 2 { Some(tag[2..].to_vec()) @@ -134,7 +133,6 @@ fn parse_comment_target( if kind != address.kind { return Err(EventParseError::InvalidTag(keys.kind)); } - validate_comment_target_kind(kind, keys.kind)?; let author = required_author(tags, keys.author)?; if author != address.pubkey { return Err(EventParseError::InvalidTag(keys.author)); @@ -204,14 +202,6 @@ fn required_numeric_kind(tags: &[Vec<String>], key: &'static str) -> Result<u32, .map_err(|err| EventParseError::InvalidNumber(key, err)) } -fn validate_comment_target_kind(kind: u32, key: &'static str) -> Result<(), EventParseError> { - if kind == KIND_POST { - Err(EventParseError::InvalidTag(key)) - } else { - Ok(()) - } -} - pub fn data_from_event( id: String, author: String, diff --git a/crates/events_codec/src/comment/encode.rs b/crates/events_codec/src/comment/encode.rs @@ -6,9 +6,7 @@ use alloc::{ }; use radroots_events::{ - comment::RadrootsComment, - kinds::{KIND_COMMENT, KIND_POST}, - social::RadrootsSocialTarget, + comment::RadrootsComment, kinds::KIND_COMMENT, social::RadrootsSocialTarget, }; use crate::error::EventEncodeError; @@ -96,7 +94,6 @@ fn push_comment_target( .ok_or(EventEncodeError::EmptyRequiredField(keys.field))?; validate_non_empty_field(author, keys.field)?; let kind = event_kind.ok_or(EventEncodeError::EmptyRequiredField(keys.field))?; - validate_comment_target_kind(kind, keys.field)?; let mut event_tag = Vec::with_capacity(2 + relays.as_ref().map_or(0, Vec::len)); event_tag.push(keys.event.to_string()); event_tag.push(id.clone()); @@ -115,7 +112,6 @@ fn push_comment_target( } => { let parsed = parse_address_tag(address, keys.field) .map_err(|_| EventEncodeError::InvalidField(keys.field))?; - validate_comment_target_kind(parsed.kind, keys.field)?; if let Some(kind) = event_kind { if *kind != parsed.kind { return Err(EventEncodeError::InvalidField(keys.field)); @@ -158,11 +154,3 @@ fn push_comment_target( } Ok(()) } - -fn validate_comment_target_kind(kind: u32, field: &'static str) -> Result<(), EventEncodeError> { - if kind == KIND_POST { - Err(EventEncodeError::InvalidField(field)) - } else { - Ok(()) - } -} diff --git a/crates/events_codec/src/list/decode.rs b/crates/events_codec/src/list/decode.rs @@ -3,7 +3,7 @@ use alloc::{string::String, vec::Vec}; use radroots_events::{ RadrootsNostrEvent, - kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_standard_list_kind}, + kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_list_set_kind, is_nip51_standard_list_kind}, list::{RadrootsList, RadrootsListEntry}, tags::TAG_R, }; @@ -41,9 +41,9 @@ pub fn list_from_tags( content: String, tags: &[Vec<String>], ) -> Result<RadrootsList, EventParseError> { - if !is_nip51_standard_list_kind(kind) { + if !is_supported_list_kind(kind) { return Err(EventParseError::InvalidKind { - expected: "nip51 standard list kind", + expected: "nip51 standard or list-set kind", got: kind, }); } @@ -54,6 +54,10 @@ pub fn list_from_tags( Ok(RadrootsList { content, entries }) } +fn is_supported_list_kind(kind: u32) -> bool { + is_nip51_standard_list_kind(kind) || is_nip51_list_set_kind(kind) +} + fn validate_relay_tags(tags: &[Vec<String>]) -> Result<(), EventParseError> { if tags.is_empty() { return Err(EventParseError::MissingTag(TAG_R)); diff --git a/crates/events_codec/src/list/encode.rs b/crates/events_codec/src/list/encode.rs @@ -2,7 +2,7 @@ use alloc::{string::String, vec::Vec}; use radroots_events::{ - kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_standard_list_kind}, + kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_list_set_kind, is_nip51_standard_list_kind}, list::{RadrootsList, RadrootsListEntry}, tags::TAG_R, }; @@ -45,7 +45,7 @@ pub fn to_wire_parts_with_kind( list: &RadrootsList, kind: u32, ) -> Result<WireEventParts, EventEncodeError> { - if !is_nip51_standard_list_kind(kind) { + if !is_supported_list_kind(kind) { return Err(EventEncodeError::InvalidKind(kind)); } if kind == KIND_LIST_READ_WRITE_RELAYS { @@ -59,6 +59,10 @@ pub fn to_wire_parts_with_kind( }) } +fn is_supported_list_kind(kind: u32) -> bool { + is_nip51_standard_list_kind(kind) || is_nip51_list_set_kind(kind) +} + fn validate_relay_entries(entries: &[RadrootsListEntry]) -> Result<(), EventEncodeError> { if entries.is_empty() { return Err(EventEncodeError::EmptyRequiredField("relay.entries")); diff --git a/crates/events_codec/tests/comment.rs b/crates/events_codec/tests/comment.rs @@ -96,13 +96,18 @@ fn comment_build_tags_requires_strict_nip22_target_fields() { )); let comment = RadrootsComment { - root: event_target(ROOT_ID, AUTHOR, KIND_POST), + root: RadrootsSocialTarget::Event { + id: ROOT_ID.to_string(), + author: None, + event_kind: Some(KIND_ARTICLE), + relays: None, + }, parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE), content: "hello".to_string(), }; assert!(matches!( comment_build_tags(&comment), - Err(EventEncodeError::InvalidField("root")) + Err(EventEncodeError::EmptyRequiredField("root")) )); } @@ -148,6 +153,20 @@ fn comment_roundtrips_event_and_address_targets() { } #[test] +fn comment_roundtrips_short_text_note_targets() { + let comment = RadrootsComment { + root: event_target(ROOT_ID, AUTHOR, KIND_POST), + parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_POST), + content: "note reply".to_string(), + }; + let parts = to_wire_parts(&comment).unwrap(); + let parsed = comment_from_tags(parts.kind, &parts.tags, &parts.content).unwrap(); + + assert_event_target(&parsed.root, ROOT_ID, AUTHOR, KIND_POST); + assert_event_target(&parsed.parent, PARENT_ID, PARENT_AUTHOR, KIND_POST); +} + +#[test] fn comment_roundtrips_external_targets() { let comment = RadrootsComment { root: external_target("https://example.test/root", "web"), @@ -178,7 +197,7 @@ fn comment_roundtrips_external_targets() { } #[test] -fn comment_from_tags_rejects_legacy_missing_and_kind1_shapes() { +fn comment_from_tags_rejects_legacy_and_missing_shapes() { let legacy_tags = vec![vec![ TAG_E_ROOT.to_string(), ROOT_ID.to_string(), @@ -215,19 +234,6 @@ fn comment_from_tags_rejects_legacy_missing_and_kind1_shapes() { comment_from_tags(KIND_COMMENT, &missing_parent_tags, "hello"), Err(EventParseError::MissingTag("e")) )); - - let kind1_tags = vec![ - vec!["E".to_string(), ROOT_ID.to_string()], - vec!["P".to_string(), AUTHOR.to_string()], - vec!["K".to_string(), KIND_POST.to_string()], - vec!["e".to_string(), PARENT_ID.to_string()], - vec!["p".to_string(), PARENT_AUTHOR.to_string()], - vec!["k".to_string(), KIND_ARTICLE.to_string()], - ]; - assert!(matches!( - comment_from_tags(KIND_COMMENT, &kind1_tags, "hello"), - Err(EventParseError::InvalidTag("K")) - )); } #[test] diff --git a/crates/events_codec/tests/list.rs b/crates/events_codec/tests/list.rs @@ -1,5 +1,5 @@ use radroots_events::{ - kinds::{KIND_LIST_MUTE, KIND_LIST_READ_WRITE_RELAYS, KIND_POST}, + kinds::{KIND_LIST_MUTE, KIND_LIST_READ_WRITE_RELAYS, KIND_LIST_SET_FOLLOW, KIND_POST}, list::{RadrootsList, RadrootsListEntry}, tags::TAG_R, }; @@ -92,13 +92,24 @@ fn list_encode_and_decode_reject_invalid_inputs() { assert!(matches!( err, EventParseError::InvalidKind { - expected: "nip51 standard list kind", + expected: "nip51 standard or list-set kind", got: KIND_POST } )); } #[test] +fn list_set_kind_roundtrips_generic_entries() { + let list = sample_list(); + let parts = to_wire_parts_with_kind(&list, KIND_LIST_SET_FOLLOW).unwrap(); + + assert_eq!(parts.kind, KIND_LIST_SET_FOLLOW); + let decoded = list_from_tags(parts.kind, parts.content, &parts.tags).unwrap(); + assert_eq!(decoded.entries.len(), list.entries.len()); + assert_eq!(decoded.entries[0].tag, "p"); +} + +#[test] fn list_entries_from_tags_rejects_empty_entry_fields() { let err = list_entries_from_tags(&[vec!["".to_string(), "x".to_string()]]).unwrap_err(); assert!(matches!(err, EventParseError::InvalidTag("tag"))); @@ -168,7 +179,7 @@ fn list_index_from_event_propagates_parse_errors() { assert!(matches!( err, EventParseError::InvalidKind { - expected: "nip51 standard list kind", + expected: "nip51 standard or list-set kind", got: KIND_POST } )); diff --git a/spec/conformance/vectors/social/mvp.v1.json b/spec/conformance/vectors/social/mvp.v1.json @@ -144,8 +144,8 @@ } }, { - "id": "social_comment_kind_one_target_invalid_006", - "kind": "social.comment.build_tags.invalid", + "id": "social_comment_kind_one_target_valid_006", + "kind": "social.comment.build_tags.valid", "input": { "comment": { "root": { @@ -166,9 +166,15 @@ } }, "expected": { - "result": "error", - "error_class": "encode_error", - "field": "root" + "result": "ok", + "required_tags": [ + "E", + "P", + "K", + "e", + "p", + "k" + ] } }, { diff --git a/spec/social-events.md b/spec/social-events.md @@ -63,7 +63,8 @@ valid. `RadrootsComment` uses strict NIP-22 semantics. The target and scope model must support event-id, address, and external roots or parents through `E`/`e`, `A`/`a`, and `I`/`i` tags with matching -`K`/`k` kind metadata. Canonical decode must reject legacy `e_root` and `e_prev` fallback tags. +`K`/`k` kind metadata, including ordinary kind `1` short text note targets. Canonical decode must +reject legacy `e_root` and `e_prev` fallback tags. `RadrootsReaction` uses strict NIP-25 semantics. Empty content, `+`, `-`, emoji, and custom reaction content are valid when the target tags are valid. Missing targets remain invalid. @@ -77,8 +78,9 @@ including URL, MIME type, SHA-256 hash, original hash, size, dimensions, blurhas summary, alt text, fallback, `magnet`, `i`, and `service`. `RadrootsListingDraft` and `RadrootsRelayList` are not separate model types in the target contract. -Listing draft kind `30403` is represented through `RadrootsListing`, and NIP-65 relay metadata kind -`10002` is represented through `RadrootsList`. +Listing draft kind `30403` is represented through `RadrootsListing`, and NIP-51 standard and +list-set entries, including NIP-65 relay metadata kind `10002`, are represented through +`RadrootsList`. ## Exclusions