update publish community
This commit is contained in:
+330
-16
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user