update publish community

This commit is contained in:
2026-09-19 09:27:41 +07:00
parent 9a391ca76d
commit 97e7539cea
5 changed files with 520 additions and 68 deletions
+330 -16
View File
@@ -92,14 +92,13 @@ pub struct Snapshot {
pub members: BTreeSet<PublicKey>,
}
/// Mints a community owned by `signer` and persists it locally.
pub async fn create<S>(
client: &Client,
signer: &S,
metadata: &cord02::CommunityMetadata,
) -> Result<CommunityState>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let at_secs = Timestamp::now().as_secs();
let genesis = cord02::genesis(signer, metadata, at_secs).await?;
@@ -112,15 +111,108 @@ where
for wrap in &genesis.wraps {
editions.push(cord02::open_edition(wrap, &read, &address, true)?);
client.database().save_event(wrap).await?;
}
let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?;
store::save_state(client, &state).await?;
publish_wraps(client, &genesis.wraps, &state.relays).await;
if let Err(error) = record_membership(client, signer, &state, &metadata.name).await {
log::warn!(
"community {}: recording the membership failed: {error}",
state.id.to_hex()
);
}
Ok(state)
}
/// Best-effort publication of the genesis wraps to the community's relays.
async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
for url in relays {
if let Err(error) = client.add_relay(url).and_connect().await {
log::warn!("community genesis: failed to add relay {url}: {error}");
}
}
for wrap in wraps {
let sent = if relays.is_empty() {
client.send_event(wrap).broadcast().await
} else {
client.send_event(wrap).to(relays.iter().cloned()).await
};
match sent {
Ok(output) if output.failed.is_empty() => {}
Ok(output) => log::warn!(
"community genesis: {} relay(s) rejected {}",
output.failed.len(),
wrap.id
),
Err(error) => log::warn!("community genesis: publishing {} failed: {error}", wrap.id),
}
}
}
async fn record_membership<S>(
client: &Client,
signer: &S,
state: &CommunityState,
name: &str,
) -> Result<()>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let self_pk = signer.get_public_key_async().await?;
let held = load_list(client, signer, self_pk).await?;
let frags = held.as_ref().map_or(1, |list| list.frags);
if frags > 1 {
log::warn!(
"community {}: the list spans {frags} fragments; deferring the membership write",
state.id.to_hex()
);
return Ok(());
}
let entry = store::list_entry(state, name);
let list = match held {
Some(held) => held.joined(entry),
None => CommunityList::default().joined(entry),
};
let previous = newest_fragment_at(client, self_pk).await?;
let now = Timestamp::now().as_secs();
let at_secs = previous.map_or(now, |previous| now.max(previous.as_secs() + 1));
let event = cord02::list::build_list_event(signer, &list, 0, at_secs).await?;
publish_list(client, &event).await;
Ok(())
}
async fn publish_list(client: &Client, event: &Event) {
match client.send_event(event).to_nip65().await {
Ok(output) if output.failed.is_empty() => {}
Ok(output) => log::warn!(
"community list: {} relay(s) rejected the publish",
output.failed.len()
),
Err(error) => log::warn!("community list: publish failed: {error}"),
}
}
/// The newest `created_at` the account holds across its list fragments.
async fn newest_fragment_at(client: &Client, self_pk: PublicKey) -> Result<Option<Timestamp>> {
Ok(newest_fragments(client, self_pk)
.await?
.into_values()
.map(|event| event.created_at)
.max())
}
/// The subscription id carrying the account's own Community List.
pub const LIST_SUBSCRIPTION: &str = "concord/list";
@@ -249,12 +341,8 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
held
}
/// Every fragment of the account's list in the local database, merged.
async fn load_list(
client: &Client,
signer: &UniversalSigner,
self_pk: PublicKey,
) -> Result<Option<CommunityList>> {
/// The newest held copy of each fragment, keyed by its `d` index.
async fn newest_fragments(client: &Client, self_pk: PublicKey) -> Result<BTreeMap<u64, Event>> {
let filter = Filter::new()
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
.author(self_pk);
@@ -274,9 +362,21 @@ async fn load_list(
}
}
Ok(newest)
}
/// Every fragment of the account's list in the local database, merged.
async fn load_list<S>(
client: &Client,
signer: &S,
self_pk: PublicKey,
) -> Result<Option<CommunityList>>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let mut merged: Option<CommunityList> = None;
for event in newest.into_values() {
for event in newest_fragments(client, self_pk).await?.into_values() {
match cord02::list::parse_list_event(signer, &event).await {
Ok(list) => {
merged = Some(match merged {
@@ -464,22 +564,55 @@ mod tests {
.any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general))
);
// The filter author-lists every plane, so the subscription actually
// reaches the events the fold reads.
let filter = subscription_filter(&planes);
let addresses: BTreeSet<PublicKey> = planes.iter().map(|plane| plane.address).collect();
assert_eq!(filter.authors, Some(addresses));
assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)])));
}
fn metadata(name: &str, relay: &str) -> cord02::CommunityMetadata {
fn metadata(name: &str) -> cord02::CommunityMetadata {
cord02::CommunityMetadata {
name: name.to_owned(),
relays: vec![relay.to_owned()],
..cord02::CommunityMetadata::default()
}
}
fn held(id: CommunityId, control_pk: PublicKey) -> CommunityState {
CommunityState {
id,
owner: Keys::generate().public_key(),
owner_salt: [0x01; 32],
community_root: [0x02; 32],
root_epoch: Epoch(0),
control_root: Some([0x03; 32]),
control_pks: BTreeMap::from([(0, control_pk)]),
channels: vec![concord::store::ChannelKeyRef {
id: ChannelId::from_bytes([0x9c; 32]),
name: "general".to_owned(),
private: false,
epoch: Epoch(0),
key: None,
}],
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
dissolved: false,
added_at_ms: 1_700_000_000_000,
}
}
/// Puts a List in the database as the account's own fragment, exactly as a
/// relay would have delivered it.
async fn store_fragment<S>(client: &Client, signer: &S, list: &CommunityList)
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let event = cord02::list::build_list_event(signer, list, 0, 1_700_000_000)
.await
.expect("builds");
client.database().save_event(&event).await.expect("saves");
}
/// What `CommunityRegistry` needs from a created community: a state document
/// `load` finds, a control plane the subscription filter actually addresses,
/// and a fold that survives an inbound control edit.
@@ -490,7 +623,7 @@ mod tests {
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let created = create(&client, &signer, &metadata("coop", "wss://relay.example"))
let created = create(&client, &signer, &metadata("coop"))
.await
.expect("creates");
@@ -547,7 +680,7 @@ mod tests {
.set_community_metadata(
&keys,
&created.id,
&metadata("coop two", "wss://relay.example"),
&metadata("coop two"),
Some(community_head),
None,
Timestamp::now().as_secs() + 1,
@@ -570,4 +703,185 @@ mod tests {
);
});
}
/// The other half of `create`: the membership must reach the account's
/// Community List, and a later create must union into it rather than replace
/// it (CORD-02 §8 read-modify-write).
#[test]
fn creating_a_community_records_the_membership_in_the_list() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let self_pk = keys.public_key();
let created = create(&client, &signer, &metadata("coop"))
.await
.expect("creates");
let list = load_list(&client, &signer, self_pk)
.await
.expect("reads")
.expect("a list");
assert_eq!(list.frags, 1);
assert!(list.is_complete([0]), "the whole list is one fragment");
assert!(list.is_live(&created.id));
let entry = list
.entries
.iter()
.find(|entry| entry.community_id == created.id)
.expect("the membership");
assert_eq!(
entry.seed, entry.current,
"a fresh membership has one anchor"
);
assert_eq!(entry.current.name, "coop");
assert_eq!(entry.current.owner, created.owner);
assert_eq!(entry.current.root_epoch, created.root_epoch);
assert_eq!(
entry.current.control_pk,
created.control_pks.get(&0).copied()
);
assert!(
entry.current.control_root.is_some(),
"the owner holds the control root"
);
assert_eq!(entry.current.channels.len(), created.channels.len());
assert_eq!(entry.added_at, created.added_at_ms);
// A second create unions into the same document: the first
// membership survives and both are live.
let second = create(&client, &signer, &metadata("second"))
.await
.expect("creates");
let grown = load_list(&client, &signer, self_pk)
.await
.expect("reads")
.expect("a list");
assert!(grown.is_live(&created.id));
assert!(grown.is_live(&second.id));
assert_eq!(grown.entries.len(), 2);
});
}
/// The discovery fix: a membership the List carries is materialized even when
/// no state document has ever been written for it.
#[test]
fn load_materializes_a_membership_the_list_carries_with_no_state_document() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let listed = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
let list = CommunityList::default().joined(store::list_entry(&listed, "coop"));
store_fragment(&client, &signer, &list).await;
assert!(
store::load_state(&client, &listed.id)
.await
.expect("reads")
.is_none()
);
let loaded = load(&client, &signer, keys.public_key())
.await
.expect("loads");
let materialized = loaded
.iter()
.find(|state| state.id == listed.id)
.expect("the list materializes the community");
assert_eq!(materialized.owner, listed.owner);
assert_eq!(materialized.community_root, listed.community_root);
assert_eq!(materialized.control_root, listed.control_root);
assert_eq!(materialized.control_pks, listed.control_pks);
assert_eq!(materialized.channels, listed.channels);
assert_eq!(materialized.added_at_ms, listed.added_at_ms);
// Discovery writes the document, so the next load is warm.
assert_eq!(
store::load_state(&client, &listed.id)
.await
.expect("reads")
.map(|state| state.id),
Some(listed.id)
);
});
}
/// Absence from the List is never a fact (§8): a held membership the List
/// does not mention survives alongside the one it does.
#[test]
fn load_keeps_a_local_membership_the_list_does_not_mention() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let listed = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
let local = held(
CommunityId::from_bytes([0x43; 32]),
Keys::generate().public_key(),
);
let list = CommunityList::default().joined(store::list_entry(&listed, "listed"));
store_fragment(&client, &signer, &list).await;
store::save_state(&client, &local).await.expect("saves");
let loaded = load(&client, &signer, keys.public_key())
.await
.expect("loads");
let ids: BTreeSet<CommunityId> = loaded.iter().map(|state| state.id).collect();
assert!(ids.contains(&listed.id));
assert!(ids.contains(&local.id));
});
}
/// Only a tombstone subtracts a membership (§8).
#[test]
fn a_tombstone_drops_a_held_membership() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let local = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
store::save_state(&client, &local).await.expect("saves");
let list = CommunityList::default().tombstoned(local.id, u64::MAX);
store_fragment(&client, &signer, &list).await;
let loaded = load(&client, &signer, keys.public_key())
.await
.expect("loads");
assert!(loaded.iter().all(|state| state.id != local.id));
});
}
/// The `concord/list` id must not be read as a community's subscription, or
/// every list event would refresh a community instead of triggering `load`.
#[test]
fn the_list_subscription_is_not_read_as_a_community_subscription() {
let id = CommunityId::from_bytes([0x42; 32]);
assert!(is_list_subscription(&list_subscription_id()));
assert!(community_of(&list_subscription_id()).is_none());
assert_eq!(community_of(&subscription_id(&id)), Some(id));
}
}
+30 -3
View File
@@ -132,6 +132,30 @@ impl CommunityList {
(0..self.frags).all(|index| held.contains(&index))
}
pub fn joined(&self, entry: CommunityListEntry) -> CommunityList {
merge(
self.clone(),
CommunityList {
entries: vec![entry],
..Default::default()
},
)
}
pub fn tombstoned(&self, community_id: CommunityId, removed_at: u64) -> CommunityList {
merge(
self.clone(),
CommunityList {
tombstones: vec![Tombstone {
community_id,
removed_at,
extra: Extra::default(),
}],
..Default::default()
},
)
}
pub fn fits(&self) -> Result<(), ListError> {
if self.entries.len() > MAX_MEMBERSHIPS {
return Err(ListError::TooManyMemberships(self.entries.len()));
@@ -207,6 +231,7 @@ pub async fn build_list_event<S>(
signer: &S,
list: &CommunityList,
fragment: u64,
at_secs: u64,
) -> Result<Event, ListError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
@@ -218,6 +243,7 @@ where
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.tag(Tag::identifier(fragment.to_string()))
.custom_created_at(Timestamp::from_secs(at_secs))
.finalize_async(signer)
.await
.map_err(crypto_error)
@@ -816,7 +842,7 @@ mod tests {
..Default::default()
};
let event = smol::block_on(build_list_event(&me, &mine, 1)).expect("builds");
let event = smol::block_on(build_list_event(&me, &mine, 1, AT_SECS)).expect("builds");
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
assert_eq!(fragment_index(&event).expect("a fragment index"), 1);
assert_eq!(
@@ -858,7 +884,7 @@ mod tests {
.insert("read_key".to_owned(), serde_json::json!("aa".repeat(32)));
let rebuilt = smol::block_on(parse_list_event(
&me,
&smol::block_on(build_list_event(&me, &held, 0)).expect("builds"),
&smol::block_on(build_list_event(&me, &held, 0, AT_SECS)).expect("builds"),
))
.expect("parses");
assert_eq!(rebuilt, held);
@@ -878,7 +904,7 @@ mod tests {
.collect(),
);
assert!(matches!(
smol::block_on(build_list_event(&me, &crowded, 0)),
smol::block_on(build_list_event(&me, &crowded, 0, AT_SECS)),
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
));
@@ -1019,4 +1045,5 @@ mod tests {
}
const AT: u64 = 1_719_800_000_000;
const AT_SECS: u64 = AT / 1000;
}
+38 -2
View File
@@ -3,18 +3,20 @@ use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;
use anyhow::{Result, anyhow};
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::cord02::list::JoinMaterial;
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
use crate::cord02::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::cord03::{self, ChatRumor, plane_keys};
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
use crate::cord05::ChannelGrant;
use crate::derive::control_signer_group_key;
use crate::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32};
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, decode_hex_32};
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -337,6 +339,40 @@ impl CommunityState {
}
}
pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry {
let material = JoinMaterial {
community_id: state.id,
owner: state.owner,
owner_salt: HEXLOWER.encode(&state.owner_salt),
community_root: HEXLOWER.encode(&state.community_root),
root_epoch: state.root_epoch,
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
control_root: state.control_root.map(|root| HEXLOWER.encode(&root)),
channels: state
.channels
.iter()
.map(|channel| ChannelGrant {
id: channel.id,
key: channel.key.map(|key| HEXLOWER.encode(&key)),
epoch: channel.epoch,
name: channel.name.clone(),
extra: Extra::default(),
})
.collect(),
relays: state.relays.iter().map(RelayUrl::to_string).collect(),
name: name.to_owned(),
extra: Extra::default(),
};
CommunityListEntry {
community_id: state.id,
seed: material.clone(),
current: material,
added_at: state.added_at_ms,
extra: Extra::default(),
}
}
fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex())
}