This commit is contained in:
2026-09-30 05:47:54 +07:00
parent 21f96c7bbe
commit 63a383c7ee
5 changed files with 37 additions and 191 deletions
-102
View File
@@ -227,105 +227,3 @@ fn state_document_of(event: &Event) -> Option<CommunityId> {
let hex = identifier.strip_prefix(STATE_PREFIX)?; let hex = identifier.strip_prefix(STATE_PREFIX)?;
hex.parse().ok() hex.parse().ok()
} }
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use concord::Epoch;
use nostr_memory::MemoryDatabase;
use super::*;
fn client() -> Client {
ClientBuilder::default()
.database(MemoryDatabase::unbounded())
.build()
}
#[test]
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
smol::block_on(async {
let client = client();
let state = CommunityState {
id: CommunityId::from_bytes([0x42; 32]),
name: Some("Anime and Manga".to_owned()),
owner: Keys::generate().public_key(),
owner_salt: [0x01; 32],
community_root: [0x02; 32],
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::new(),
channels: Vec::new(),
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
refounders: BTreeSet::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: 7,
};
save_state(&client, &state).await.expect("saves");
// A cached rumor is also an application-specific document, but not a
// state document, so the prefix keeps it out of the state scan.
let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
.tags([Tag::identifier("deadbeef")])
.finalize(&*LOCAL_KEYS)
.expect("builds");
client.database().save_event(&other).await.expect("saves");
let loaded = load_states(&client).await.expect("loads");
assert_eq!(loaded, vec![state]);
});
}
#[test]
fn caching_the_same_rumor_twice_leaves_one_row() {
smol::block_on(async {
let client = client();
let channel = ChannelId::from_bytes([0x9c; 32]);
let keys = Keys::generate();
let group = concord::derive::channel_group_key(&[0x07; 32], &channel, Epoch(0))
.expect("a group key");
let rumor = concord::cord03::build_message(
keys.public_key(),
&channel,
Epoch(0),
"twice",
None,
1_700_000_000_000,
None,
);
let (wrap, _) = concord::cord03::seal_rumor(&rumor, &group, &keys, false)
.await
.expect("seals");
let (opened, _) =
concord::cord03::open(&wrap, &group, &channel, Epoch(0)).expect("opens");
assert!(
cache_rumor(&client, &channel, &opened)
.await
.expect("caches")
);
assert!(
cache_rumor(&client, &channel, &opened)
.await
.expect("caches")
);
let cached = query_rumors(&client, &channel, None, 10, None)
.await
.expect("reads");
assert_eq!(cached.len(), 1);
});
}
}
+34 -70
View File
@@ -19,7 +19,7 @@ use state::NostrRegistry;
use crate::cache; use crate::cache;
use crate::history::{self, PageRegistry, Window, WrapPage}; use crate::history::{self, PageRegistry, Window, WrapPage};
use crate::rekey::{self, Adoptions}; use crate::rekey::{self, Adoptions};
use crate::sync::{self, Snapshot}; use crate::sync::{self};
/// Wraps one relay returns for one page request. /// Wraps one relay returns for one page request.
const PAGE_WRAPS: usize = 50; const PAGE_WRAPS: usize = 50;
@@ -124,10 +124,6 @@ pub struct Community {
icon_ref: Option<ImageRef>, icon_ref: Option<ImageRef>,
banner: Option<PathBuf>, banner: Option<PathBuf>,
banner_ref: Option<ImageRef>, banner_ref: Option<ImageRef>,
dirty: bool,
refresh_task: Option<Task<Result<()>>>,
icon_task: Option<Task<Result<()>>>,
banner_task: Option<Task<Result<()>>>,
rounds: HashMap<ChannelId, Round>, rounds: HashMap<ChannelId, Round>,
/// The last completed round per channel, for the panel's honest states. /// The last completed round per channel, for the panel's honest states.
progress: HashMap<ChannelId, Progress>, progress: HashMap<ChannelId, Progress>,
@@ -137,8 +133,6 @@ pub struct Community {
last_round: HashMap<ChannelId, Instant>, last_round: HashMap<ChannelId, Instant>,
pages: PageRegistry, pages: PageRegistry,
rekey_task: Option<Task<Result<()>>>, rekey_task: Option<Task<Result<()>>>,
rekey_dirty: bool,
/// Spawned folds, round bookkeeping and publishes, cancelled on drop
tasks: SmallVec<[Task<Result<()>>; 2]>, tasks: SmallVec<[Task<Result<()>>; 2]>,
} }
@@ -155,17 +149,12 @@ impl Community {
icon_ref: None, icon_ref: None,
banner: None, banner: None,
banner_ref: None, banner_ref: None,
dirty: false,
refresh_task: None,
icon_task: None,
banner_task: None,
rounds: HashMap::new(), rounds: HashMap::new(),
progress: HashMap::new(), progress: HashMap::new(),
unreadable: BTreeMap::new(), unreadable: BTreeMap::new(),
last_round: HashMap::new(), last_round: HashMap::new(),
pages, pages,
rekey_task: None, rekey_task: None,
rekey_dirty: false,
tasks: smallvec![], tasks: smallvec![],
} }
} }
@@ -620,7 +609,6 @@ impl Community {
let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?; let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?;
cache::cache_rumor(&client, &channel, &opened).await?; cache::cache_rumor(&client, &channel, &opened).await?;
sync::connect_relays(&client, &relays).await;
sync::publish_wrap(&client, &wrap, &relays).await; sync::publish_wrap(&client, &wrap, &relays).await;
Ok(opened.rumor_id) Ok(opened.rumor_id)
@@ -631,16 +619,12 @@ impl Community {
/// ///
/// The caller re-folds afterwards; this only seeds the new planes. /// The caller re-folds afterwards; this only seeds the new planes.
pub(crate) fn adopt(&mut self, state: CommunityState) { pub(crate) fn adopt(&mut self, state: CommunityState) {
// A fold already in flight would write its pre-adoption state back.
self.refresh_task = None;
self.dirty = false;
self.state = state; self.state = state;
} }
/// Adopt whatever the rekey watch has delivered, then re-page what it moved. /// Adopt whatever the rekey watch has delivered, then re-page what it moved.
pub fn rekey(&mut self, cx: &mut Context<Self>) { pub fn rekey(&mut self, cx: &mut Context<Self>) {
if self.rekey_task.is_some() { if self.rekey_task.is_some() {
self.rekey_dirty = true;
return; return;
} }
@@ -651,6 +635,7 @@ impl Community {
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let state = self.state.clone(); let state = self.state.clone();
let roles = self.control.roles.clone(); let roles = self.control.roles.clone();
@@ -746,11 +731,6 @@ impl Community {
Ok(_) => {} Ok(_) => {}
Err(error) => cx.emit(CommunityEvent::Error(error.to_string())), Err(error) => cx.emit(CommunityEvent::Error(error.to_string())),
} }
if self.rekey_dirty {
self.rekey_dirty = false;
self.rekey(cx);
}
} }
/// Fold an adoption into the held state, persist it, and re-page what moved. /// Fold an adoption into the held state, persist it, and re-page what moved.
@@ -818,8 +798,6 @@ impl Community {
continue; continue;
}; };
// `stepped` carries the key held before the walk plus every epoch it
// passed through, each with the cutoff its superseding rotation set.
for key in channel.stepped { for key in channel.stepped {
if !held.priors.iter().any(|prior| prior.epoch == key.epoch) { if !held.priors.iter().any(|prior| prior.epoch == key.epoch) {
held.priors.push(key); held.priors.push(key);
@@ -889,67 +867,53 @@ impl Community {
/// Rebuilds the community from the wraps in the local database. /// Rebuilds the community from the wraps in the local database.
pub fn refresh(&mut self, cx: &mut Context<Self>) { pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh_task.is_some() {
self.dirty = true;
return;
}
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let state = self.state.clone(); let state = self.state.clone();
let folded = cx.background_spawn(async move { sync::fold(&client, &state).await }); let folded = cx.background_spawn(async move { sync::fold(&client, &state).await });
self.refresh_task = Some(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let result = folded.await; let result = folded.await;
this.update(cx, |this, cx| this.apply(result, cx))?;
this.update(cx, |this, cx| {
match result {
Ok(Some(snapshot)) => {
let mut state = snapshot.state;
state.cursors = std::mem::take(&mut this.state.cursors);
this.state = state;
this.control = snapshot.control;
this.members = snapshot.members;
this.load_images(cx);
let reported = !snapshot.unreadable.is_empty();
this.unreadable = snapshot.unreadable;
if reported {
cx.emit(CommunityEvent::Unreadable(this.state.id));
}
cx.emit(CommunityEvent::Updated(this.state.id));
cx.notify();
}
Err(error) => {
cx.emit(CommunityEvent::Error(error.to_string()));
}
_ => {}
};
})?;
Ok(()) Ok(())
})); }));
} }
fn apply(&mut self, result: Result<Option<Snapshot>>, cx: &mut Context<Self>) {
self.refresh_task = None;
match result {
Ok(Some(snapshot)) => {
let mut state = snapshot.state;
state.cursors = std::mem::take(&mut self.state.cursors);
self.state = state;
self.control = snapshot.control;
self.members = snapshot.members;
self.load_images(cx);
// A fold reads every plane, so its count is the truth for every
// channel rather than a sample of the region one round read.
// Replacing it is what lets the count fall again once a key is
// adopted; a partial round only ever raises it.
let reported = !snapshot.unreadable.is_empty();
self.unreadable = snapshot.unreadable;
if reported {
cx.emit(CommunityEvent::Unreadable(self.state.id));
}
cx.emit(CommunityEvent::Updated(self.state.id));
cx.notify();
}
Ok(None) => {}
Err(error) => cx.emit(CommunityEvent::Error(error.to_string())),
}
if self.dirty {
self.dirty = false;
self.refresh(cx);
}
}
/// Resolve the folded icon and banner into local files. /// Resolve the folded icon and banner into local files.
fn load_images(&mut self, cx: &mut Context<Self>) { fn load_images(&mut self, cx: &mut Context<Self>) {
let (icon, banner) = match self.control.community.as_ref() { let (icon, banner) = match self.control.community.as_ref() {
Some(metadata) => (metadata.icon.clone(), metadata.banner.clone()), Some(metadata) => (metadata.icon.clone(), metadata.banner.clone()),
None => (None, None), None => (None, None),
}; };
self.load_icon(icon, cx); self.load_icon(icon, cx);
self.load_banner(banner, cx); self.load_banner(banner, cx);
} }
@@ -966,7 +930,7 @@ impl Community {
return; return;
}; };
self.icon_task = Some(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match sync::resolve_image(&icon, cx).await { match sync::resolve_image(&icon, cx).await {
Ok(path) => { Ok(path) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -992,7 +956,7 @@ impl Community {
return; return;
}; };
self.banner_task = Some(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match sync::resolve_image(&banner, cx).await { match sync::resolve_image(&banner, cx).await {
Ok(path) => { Ok(path) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
-3
View File
@@ -24,10 +24,7 @@ mod sync;
pub use community::*; pub use community::*;
pub use sync::*; pub use sync::*;
/// How long a burst of relay notifications is collected before it is folded.
const PUMP_WINDOW: Duration = Duration::from_millis(200); const PUMP_WINDOW: Duration = Duration::from_millis(200);
/// How long a community's relays may deliver nothing before
/// its standing subscription is torn down and re-issued.
const LIVE_ROTATE: Duration = Duration::from_secs(90); const LIVE_ROTATE: Duration = Duration::from_secs(90);
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
+1 -14
View File
@@ -29,8 +29,6 @@ pub struct Watch {
pub epoch: Epoch, pub epoch: Epoch,
} }
/// The permission a rotation of `scope` is judged under, by whoever reads it and
/// by this client when it publishes one.
fn permissions(scope: RekeyScope) -> &'static [u64] { fn permissions(scope: RekeyScope) -> &'static [u64] {
match scope { match scope {
RekeyScope::Base => &[Permissions::BAN], RekeyScope::Base => &[Permissions::BAN],
@@ -39,11 +37,6 @@ fn permissions(scope: RekeyScope) -> &'static [u64] {
} }
/// Every address a community's rotations can arrive at. /// Every address a community's rotations can arrive at.
///
/// The base scope watches the epoch after *every* held root, not only the
/// current one: a rotation published while this client was away is read from
/// the root it stepped off, and the npub that minted an epoch is only knowable
/// from the rotation that minted it.
pub fn watches(state: &CommunityState) -> Result<Vec<Watch>> { pub fn watches(state: &CommunityState) -> Result<Vec<Watch>> {
let mut watches = Vec::new(); let mut watches = Vec::new();
let roots = state.roots(); let roots = state.roots();
@@ -185,8 +178,8 @@ where
S: AsyncNip44 + ?Sized, S: AsyncNip44 + ?Sized,
{ {
let watches = watches(state)?; let watches = watches(state)?;
let mut authors: BTreeSet<PublicKey> = BTreeSet::new(); let mut authors: BTreeSet<PublicKey> = BTreeSet::new();
for watch in &watches { for watch in &watches {
authors.insert(watch.address); authors.insert(watch.address);
} }
@@ -666,12 +659,6 @@ where
.iter() .iter()
.filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends) .filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends)
{ {
// Who minted an epoch is proven by the rotation itself, not by a blob:
// a rotation is only a candidate after continuity against a key we
// hold, so its rotator minted this epoch whether or not it addressed
// us. A member who joined on a stale bundle never held the epochs
// between, and the snapshot that seeds them is only honored on this
// npub's authority (CORD-02 §5).
if scope == RekeyScope::Base { if scope == RekeyScope::Base {
step.refounders.insert(rotation.rotator); step.refounders.insert(rotation.rotator);
} }
+2 -2
View File
@@ -214,8 +214,6 @@ where
/// Best-effort publication of the genesis wraps to the community's relays. /// Best-effort publication of the genesis wraps to the community's relays.
pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) { pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
connect_relays(client, relays).await;
for wrap in wraps { for wrap in wraps {
publish_wrap(client, wrap, relays).await; publish_wrap(client, wrap, relays).await;
} }
@@ -232,6 +230,8 @@ pub(crate) async fn connect_relays(client: &Client, relays: &[RelayUrl]) {
/// Best-effort publication of a single wrap to the community's relays. /// Best-effort publication of a single wrap to the community's relays.
pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) { pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) {
connect_relays(client, relays).await;
let sent = if relays.is_empty() { let sent = if relays.is_empty() {
client.send_event(wrap).broadcast().await client.send_event(wrap).broadcast().await
} else { } else {