Add relay support for NIP-51 lists (#616)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-05-20 13:59:22 -04:00
committed by GitHub
co-authored by Dawn
parent b5e5a39bf0
commit 23c8fa72c7
4 changed files with 298 additions and 19 deletions
+146
View File
@@ -1,4 +1,9 @@
use nostr::{EventBuilder, Kind, Tag};
use serde::Deserialize;
use sprout_sdk::kind::{
KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_FOLLOW_SET, KIND_MUTE_LIST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST,
};
use crate::client::SproutClient;
use crate::error::CliError;
@@ -112,6 +117,90 @@ pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result
Ok(())
}
fn validate_social_list_kind(kind: u32) -> Result<(), CliError> {
match kind {
KIND_MUTE_LIST
| KIND_PIN_LIST
| KIND_NIP65_RELAY_LIST_METADATA
| KIND_BOOKMARK_LIST
| KIND_FOLLOW_SET
| KIND_BOOKMARK_SET => Ok(()),
_ => Err(CliError::Usage(format!(
"unsupported social list kind {kind}; supported kinds: 10000, 10001, 10002, 10003, 30000, 30003"
))),
}
}
fn is_parameterized_social_list_kind(kind: u32) -> bool {
matches!(kind, KIND_FOLLOW_SET | KIND_BOOKMARK_SET)
}
fn parse_tags_json(tags_json: &str) -> Result<Vec<Tag>, CliError> {
let raw_tags: Vec<Vec<String>> = serde_json::from_str(tags_json)
.map_err(|e| CliError::Usage(format!("invalid tags JSON: {e}")))?;
raw_tags
.iter()
.map(|parts| {
let refs: Vec<&str> = parts.iter().map(String::as_str).collect();
Tag::parse(&refs).map_err(|e| CliError::Usage(format!("invalid tag {parts:?}: {e}")))
})
.collect::<Result<_, _>>()
}
fn has_d_tag(tags: &[Tag]) -> bool {
tags.iter()
.any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("d"))
}
pub async fn cmd_set_list(
client: &SproutClient,
kind: u16,
tags_json: &str,
content: &str,
) -> Result<(), CliError> {
let kind_u32 = u32::from(kind);
validate_social_list_kind(kind_u32)?;
let tags = parse_tags_json(tags_json)?;
if is_parameterized_social_list_kind(kind_u32) && !has_d_tag(&tags) {
return Err(CliError::Usage(format!(
"kind {kind} is parameterized replaceable and requires a d tag"
)));
}
let builder = EventBuilder::new(Kind::Custom(kind), content, tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_list(
client: &SproutClient,
pubkey: &str,
kind: u32,
d_tag: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
validate_social_list_kind(kind)?;
if !is_parameterized_social_list_kind(kind) && d_tag.is_some() {
return Err(CliError::Usage(format!(
"kind {kind} is not parameterized; omit --d-tag"
)));
}
let mut filter = serde_json::json!({
"kinds": [kind],
"authors": [pubkey],
"limit": 10
});
if let Some(d) = d_tag {
filter["#d"] = serde_json::json!([d]);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
@@ -130,5 +219,62 @@ pub async fn dispatch(cmd: crate::SocialCmd, client: &SproutClient) -> Result<()
before,
} => cmd_get_user_notes(client, &pubkey, limit, before).await,
SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await,
SocialCmd::SetList {
kind,
tags,
content,
} => cmd_set_list(client, kind, &tags, &content).await,
SocialCmd::GetList {
pubkey,
kind,
d_tag,
} => cmd_get_list(client, &pubkey, kind, d_tag.as_deref()).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn social_list_kind_validation_accepts_supported_kinds() {
for kind in [
KIND_MUTE_LIST,
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
] {
assert!(validate_social_list_kind(kind).is_ok(), "kind {kind}");
}
}
#[test]
fn social_list_kind_validation_rejects_unsupported_kinds() {
let err = validate_social_list_kind(30002).unwrap_err();
assert!(
matches!(err, CliError::Usage(msg) if msg.contains("unsupported social list kind 30002"))
);
}
#[test]
fn parses_tags_json_and_detects_d_tag() {
let tags = parse_tags_json(r#"[["d","friends"],["p","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]]"#)
.expect("tags parse");
assert!(has_d_tag(&tags));
}
#[test]
fn malformed_tags_json_is_usage_error() {
let err = parse_tags_json("not json").unwrap_err();
assert!(matches!(err, CliError::Usage(msg) if msg.contains("invalid tags JSON")));
}
#[test]
fn parameterized_social_list_kind_detection() {
assert!(is_parameterized_social_list_kind(KIND_FOLLOW_SET));
assert!(is_parameterized_social_list_kind(KIND_BOOKMARK_SET));
assert!(!is_parameterized_social_list_kind(KIND_MUTE_LIST));
}
}
+36 -2
View File
@@ -746,6 +746,32 @@ pub enum SocialCmd {
#[arg(long)]
pubkey: String,
},
/// Publish a NIP-51/NIP-65 social list or set.
#[command(name = "set-list")]
SetList {
/// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003.
#[arg(long)]
kind: u16,
/// JSON array of Nostr tags, e.g. [["p","<hex>"],["d","friends"]].
#[arg(long)]
tags: String,
/// Event content.
#[arg(long, default_value = "")]
content: String,
},
/// Get NIP-51/NIP-65 social lists or sets by author and kind.
#[command(name = "list")]
GetList {
/// 64-char hex pubkey of the author.
#[arg(long)]
pubkey: String,
/// Supported kind: 10000, 10001, 10002, 10003, 30000, or 30003.
#[arg(long)]
kind: u32,
/// Optional d-tag for parameterized replaceable sets.
#[arg(long)]
d_tag: Option<String>,
},
}
// ---------------------------------------------------------------------------
@@ -1041,7 +1067,15 @@ mod tests {
assert_eq!(names(&cmd, "feed"), vec!["get"]);
assert_eq!(
names(&cmd, "social"),
vec!["contacts", "event", "notes", "publish", "set-contacts"]
vec![
"contacts",
"event",
"list",
"notes",
"publish",
"set-contacts",
"set-list"
]
);
assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]);
assert_eq!(names(&cmd, "upload"), vec!["file"]);
@@ -1059,7 +1093,7 @@ mod tests {
("pack", 2),
("reactions", 3),
("repos", 3),
("social", 5),
("social", 7),
("upload", 1),
("users", 4),
("workflows", 8),
+34
View File
@@ -11,6 +11,34 @@ pub const KIND_PROFILE: u32 = 0;
pub const KIND_TEXT_NOTE: u32 = 1;
/// NIP-02: Contact list / follow list.
pub const KIND_CONTACT_LIST: u32 = 3;
/// NIP-51: Mute list (replaceable, 1000019999 range) — pubkeys/events/threads/words a user has muted.
///
/// User-owned global state, keyed by `(pubkey, kind)`. Same ownership/scope shape as kind:3.
pub const KIND_MUTE_LIST: u32 = 10000;
/// NIP-51: Pin list (replaceable) — events the user has pinned to their profile.
///
/// User-owned global state, keyed by `(pubkey, kind)`. The events referenced may live in
/// channels, but the pin list itself is profile-level state.
pub const KIND_PIN_LIST: u32 = 10001;
/// NIP-65: Relay list metadata (replaceable) — read/write relay preferences for the outbox model.
///
/// User-owned global state, keyed by `(pubkey, kind)`. Tags are `["r", url]` or
/// `["r", url, "read"]` / `["r", url, "write"]`.
pub const KIND_NIP65_RELAY_LIST_METADATA: u32 = 10002;
/// NIP-51: Bookmark list (replaceable) — events/articles/hashtags/URLs the user has bookmarked.
///
/// User-owned global state, keyed by `(pubkey, kind)`. References content but is not itself
/// channel-scoped content.
pub const KIND_BOOKMARK_LIST: u32 = 10003;
/// NIP-51: Follow set (parameterized replaceable, 3000039999 range) — named curated lists of pubkeys.
///
/// User-owned, keyed by `(pubkey, kind, d_tag)`. Allows multiple named follow lists on top of
/// the single kind:3 contact list (e.g. "close-friends", "news", "devs").
pub const KIND_FOLLOW_SET: u32 = 30000;
/// NIP-51: Bookmark set (parameterized replaceable) — named curated bookmark collections.
///
/// User-owned, keyed by `(pubkey, kind, d_tag)`.
pub const KIND_BOOKMARK_SET: u32 = 30003;
/// NIP-01: Channel metadata (replaceable). Not used by Sprout today.
pub const KIND_CHANNEL_METADATA: u32 = 41;
/// NIP-09: Event deletion request.
@@ -322,6 +350,12 @@ pub const ALL_KINDS: &[u32] = &[
KIND_PROFILE,
KIND_TEXT_NOTE,
KIND_CONTACT_LIST,
KIND_MUTE_LIST,
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
KIND_CHANNEL_METADATA,
KIND_DELETION,
KIND_REACTION,
+82 -17
View File
@@ -13,23 +13,24 @@ use nostr::Event;
use sprout_auth::Scope;
use sprout_core::kind::{
event_kind_u32, is_parameterized_replaceable, is_relay_admin_kind, KIND_AGENT_ENGRAM,
KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST,
KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_FORUM_COMMENT,
KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH,
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE,
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN,
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED,
KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT,
KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST,
KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
KIND_NIP43_LEAVE_REQUEST, KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS,
KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
RELAY_ADMIN_REMOVE_MEMBER,
KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET,
KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN,
KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE,
KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MUTE_LIST,
KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP,
KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST,
KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PROFILE,
KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED,
KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
};
use sprout_core::verification::verify_event;
@@ -153,6 +154,14 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM => {
Ok(Scope::UsersWrite)
}
// NIP-51 standard lists and NIP-65 relay list — user-owned global state,
// same ownership shape as kind:3 (contacts) and kind:0 (profile).
KIND_MUTE_LIST
| KIND_PIN_LIST
| KIND_NIP65_RELAY_LIST_METADATA
| KIND_BOOKMARK_LIST
| KIND_FOLLOW_SET
| KIND_BOOKMARK_SET => Ok(Scope::UsersWrite),
KIND_DELETION
| KIND_REACTION
| KIND_GIFT_WRAP
@@ -302,6 +311,15 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_LONG_FORM
| KIND_USER_STATUS
| KIND_READ_STATE
// NIP-51 standard lists + sets and NIP-65 relay list — user-owned global state.
// Same as kind:3 (contacts): keyed by (pubkey, kind) or (pubkey, kind, d_tag),
// never channel-scoped. A stray `h` tag must not channel-scope them.
| KIND_MUTE_LIST
| KIND_PIN_LIST
| KIND_NIP65_RELAY_LIST_METADATA
| KIND_BOOKMARK_LIST
| KIND_FOLLOW_SET
| KIND_BOOKMARK_SET
// NIP-AE agent engrams are addressed by (pubkey_a, kind, d_tag); never channel-scoped.
| KIND_AGENT_ENGRAM
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
@@ -1840,6 +1858,13 @@ mod tests {
KIND_FORUM_COMMENT,
KIND_LONG_FORM,
KIND_USER_STATUS,
// NIP-51 lists + sets, NIP-65 relay list
KIND_MUTE_LIST,
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
KIND_AGENT_ENGRAM,
];
for kind in migrated {
@@ -1850,6 +1875,46 @@ mod tests {
}
}
#[test]
fn nip51_and_nip65_lists_require_users_write() {
let dummy = make_dummy_event();
for kind in [
KIND_MUTE_LIST,
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
] {
assert_eq!(
required_scope_for_kind(kind, &dummy).ok(),
Some(Scope::UsersWrite),
"kind {kind} should require UsersWrite scope"
);
}
}
#[test]
fn nip51_and_nip65_lists_are_global_only() {
for kind in [
KIND_MUTE_LIST,
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
] {
assert!(
is_global_only_kind(kind),
"kind {kind} should be global-only (never channel-scoped)"
);
assert!(
!requires_h_channel_scope(kind),
"kind {kind} must not require an h-tag channel scope"
);
}
}
#[test]
fn unknown_kind_rejected() {
let dummy = make_dummy_event();