feat(sdk): read the channel creation time and pick the oldest channel

ChannelIdentity now reads the created_at tag from kind:39000. The new
oldest_channel helper ranks matching kind:39000 events by creation time,
then by channel ID, so every reader picks the same channel after two
creates race.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-10-08 14:58:28 -04:00
parent 9b633b408e
commit 4641fdfb5f
2 changed files with 77 additions and 5 deletions

View File

@@ -143,6 +143,9 @@ pub struct GroupStateIdentity {
/// The first `P` value: the hex key that created the channel. Trust it
/// only when the relay key signed the event.
pub created_by: Option<String>,
/// The first [`CHANNEL_CREATED_AT_TAG`] value: when the channel was
/// created, in Unix seconds. Only kind:39000 carries it.
pub created_at: Option<u64>,
}
impl GroupStateIdentity {
@@ -159,13 +162,16 @@ impl GroupStateIdentity {
"P" if identity.created_by.is_none() => {
identity.created_by = Some(value.to_owned());
}
CHANNEL_CREATED_AT_TAG if identity.created_at.is_none() => {
identity.created_at = value.parse().ok();
}
_ => {}
}
}
identity
}
/// Read the channel type, labels and creator from Nostr tags.
/// Read the channel type, labels, creator and creation time from Nostr tags.
pub fn from_tags<'a>(tags: impl IntoIterator<Item = &'a nostr::Tag>) -> Self {
Self::from_tag_pairs(tags.into_iter().filter_map(|tag| {
let slice = tag.as_slice();
@@ -174,6 +180,29 @@ impl GroupStateIdentity {
}
}
/// Pick the channel every reader agrees on when several kind:39000 events
/// match the same lookup, for example two channels that one creator made at
/// the same time for one label.
///
/// The oldest channel wins: the lowest [`CHANNEL_CREATED_AT_TAG`], then the
/// lowest channel ID (`d` tag). An event without a creation time ranks after
/// every event that has one. The caller checks that the relay key signed the
/// events.
pub fn oldest_channel<'a>(
events: impl IntoIterator<Item = &'a nostr::Event>,
) -> Option<&'a nostr::Event> {
events.into_iter().min_by(|a, b| {
let key = |event: &'a nostr::Event| {
let created_at = GroupStateIdentity::from_tags(event.tags.iter()).created_at;
(
created_at.unwrap_or(u64::MAX),
event.tags.identifier().unwrap_or_default(),
)
};
key(a).cmp(&key(b))
})
}
/// Whether a channel is publicly visible or invite-only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelVisibility {
@@ -338,8 +367,9 @@ impl FromStr for MemberRole {
#[cfg(test)]
mod tests {
use super::{
canonical_channel_name, channel_type_from_group_state_tags, group_state_identity_tags,
parse_channel_labels, ChannelLabelError, GroupStateIdentity, MAX_CHANNEL_LABELS,
canonical_channel_name, channel_created_at_tag, channel_type_from_group_state_tags,
group_state_identity_tags, oldest_channel, parse_channel_labels, ChannelLabelError,
GroupStateIdentity, MAX_CHANNEL_LABELS,
};
#[test]
@@ -367,12 +397,14 @@ mod tests {
let mut tags = vec![nostr::Tag::parse(["d", "channel"]).unwrap()];
tags.extend(group_state_identity_tags("forum", &labels, &creator).unwrap());
tags.push(nostr::Tag::parse(["p", &"cd".repeat(32), "", "member"]).unwrap());
tags.push(channel_created_at_tag(1_700_000_000).unwrap());
assert_eq!(
GroupStateIdentity::from_tags(&tags),
GroupStateIdentity {
channel_type: Some("forum".to_string()),
labels,
created_by: Some("ab".repeat(32)),
created_at: Some(1_700_000_000),
}
);
assert_eq!(
@@ -381,6 +413,43 @@ mod tests {
);
}
/// Mutation: rank by the event's own created_at, or drop the ID tiebreak
/// → RED.
#[test]
fn oldest_channel_ranks_by_creation_time_then_id() {
let keys = nostr::Keys::generate();
let metadata = |id: &str, created: Option<i64>, edited: u64| {
let mut tags = vec![nostr::Tag::parse(["d", id]).unwrap()];
if let Some(created) = created {
tags.push(channel_created_at_tag(created).unwrap());
}
nostr::EventBuilder::new(nostr::Kind::Custom(39000), "")
.tags(tags)
.custom_created_at(nostr::Timestamp::from(edited))
.sign_with_keys(&keys)
.unwrap()
};
let id = |event: Option<&nostr::Event>| {
event.and_then(|e| e.tags.identifier()).map(str::to_owned)
};
// `b` was created first but edited last.
let a = metadata("aaaa", Some(200), 300);
let b = metadata("bbbb", Some(100), 900);
assert_eq!(id(oldest_channel([&a, &b])), Some("bbbb".into()));
assert_eq!(id(oldest_channel([&b, &a])), Some("bbbb".into()));
// Same creation second: the lower ID wins in any order.
let c = metadata("cccc", Some(100), 100);
assert_eq!(id(oldest_channel([&c, &b])), Some("bbbb".into()));
assert_eq!(id(oldest_channel([&b, &c])), Some("bbbb".into()));
// No creation time ranks last.
let old_relay = metadata("0000", None, 1);
assert_eq!(id(oldest_channel([&old_relay, &c])), Some("cccc".into()));
assert_eq!(id(oldest_channel(std::iter::empty())), None);
}
#[test]
fn channel_labels_accept_the_allowed_alphabet_and_drop_duplicates() {
assert_eq!(

View File

@@ -77,12 +77,15 @@ pub struct CustomEmoji {
/// Return a channel name without client-rendered leading hash prefixes.
pub use buzz_core::channel::canonical_channel_name;
/// Pick the oldest of several matching kind:39000 events, so every reader
/// agrees on one channel.
pub use buzz_core::channel::oldest_channel;
/// Channel type.
pub use buzz_core::channel::ChannelType as ChannelKind;
/// Channel visibility.
pub use buzz_core::channel::ChannelVisibility as Visibility;
/// Channel type, labels and creator read from a relay-signed group-state
/// event (kinds 39000–39003). See [`ChannelIdentity::from_tags`].
/// Channel type, labels, creator and creation time read from a relay-signed
/// group-state event (kinds 39000–39003). See [`ChannelIdentity::from_tags`].
pub use buzz_core::channel::GroupStateIdentity as ChannelIdentity;
/// Member role.
pub use buzz_core::channel::MemberRole;