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