.
This commit is contained in:
@@ -264,6 +264,7 @@ mod tests {
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
|
||||
@@ -298,8 +298,9 @@ impl Community {
|
||||
return None;
|
||||
}
|
||||
|
||||
held.current()
|
||||
.or_else(|| (!held.private).then_some((held.epoch, self.state.community_root)))
|
||||
held.current().or_else(|| {
|
||||
(!held.private).then_some((self.state.root_epoch, self.state.community_root))
|
||||
})
|
||||
}
|
||||
|
||||
/// Every secret the client holds for a channel, newest epoch first.
|
||||
@@ -354,12 +355,14 @@ impl Community {
|
||||
let relays = self.state.relays.clone();
|
||||
let pages = self.pages.clone();
|
||||
|
||||
let saved = self
|
||||
.state
|
||||
let saved = clamped(
|
||||
self.state
|
||||
.cursors
|
||||
.get(&channel)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default(),
|
||||
Timestamp::now(),
|
||||
);
|
||||
|
||||
if let Some(round) = self.rounds.get_mut(&channel) {
|
||||
round.running = true;
|
||||
@@ -475,7 +478,8 @@ impl Community {
|
||||
.get(&channel)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let merged = held.merge(cursor);
|
||||
|
||||
let merged = clamped(held.merge(cursor), Timestamp::now());
|
||||
|
||||
if merged == held {
|
||||
return;
|
||||
@@ -593,7 +597,7 @@ impl Community {
|
||||
|
||||
Some(cx.background_spawn(async move {
|
||||
let group = channel_group_key(&secret, &channel, epoch)?;
|
||||
let at_ms = Timestamp::now().as_secs().saturating_mul(1000);
|
||||
let at_ms = now_ms()?;
|
||||
|
||||
let rumor = cord03::build_message(
|
||||
author,
|
||||
@@ -772,6 +776,10 @@ impl Community {
|
||||
self.state.removed_at = None;
|
||||
self.state.stranded = false;
|
||||
|
||||
// Who minted these epochs is only ever stated by the rotation itself,
|
||||
// and a Guestbook snapshot is honored on that authority.
|
||||
self.state.refounders.extend(base.refounders);
|
||||
|
||||
// Every channel's plane moved with the root.
|
||||
touched.extend(self.state.channels.iter().map(|channel| channel.id));
|
||||
}
|
||||
@@ -968,6 +976,25 @@ impl Community {
|
||||
/// One round's progress and the cursor material it earned.
|
||||
type RoundOutcome = (Progress, ChannelCursor);
|
||||
|
||||
/// A cursor as a filter bound may use it.
|
||||
fn clamped(cursor: ChannelCursor, now: Timestamp) -> ChannelCursor {
|
||||
ChannelCursor {
|
||||
newest: cursor.newest.map(|newest| newest.min(now)),
|
||||
..cursor
|
||||
}
|
||||
}
|
||||
|
||||
/// The wall clock in epoch milliseconds: a rumor's `ms` tag carries the part of
|
||||
/// the second the message was written in, and the fold orders rows by it.
|
||||
fn now_ms() -> Result<u64> {
|
||||
let elapsed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|error| anyhow::anyhow!("the system clock is before the Unix epoch: {error}"))?;
|
||||
|
||||
u64::try_from(elapsed.as_millis())
|
||||
.map_err(|error| anyhow::anyhow!("the system clock is out of range: {error}"))
|
||||
}
|
||||
|
||||
/// Read a channel's history from the community's relays, in three passes.
|
||||
async fn sync_round(
|
||||
client: &Client,
|
||||
@@ -1098,6 +1125,41 @@ fn absorb(progress: &mut Progress, page: &WrapPage) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A stamp ahead of the local clock must never become a cursor.
|
||||
///
|
||||
/// A durable `newest` past `now` bounds every later REQ below a region that
|
||||
/// has not happened yet, so the channel receives nothing at all — not live,
|
||||
/// not by round — while its older history keeps working, which is exactly
|
||||
/// what a peer with a fast clock (or a hostile stamp) would cause.
|
||||
#[test]
|
||||
fn a_future_stamp_cannot_push_a_cursor_past_now() {
|
||||
let now = Timestamp::now();
|
||||
let held = ChannelCursor {
|
||||
newest: Some(now - Duration::from_secs(30)),
|
||||
oldest: Some(now - Duration::from_secs(90)),
|
||||
exhausted: false,
|
||||
};
|
||||
let round = ChannelCursor {
|
||||
newest: Some(now + Duration::from_secs(86_400)),
|
||||
oldest: None,
|
||||
exhausted: true,
|
||||
};
|
||||
|
||||
let merged = clamped(held.merge(round), now);
|
||||
|
||||
assert_eq!(merged.newest, Some(now), "the frontier stops at the clock");
|
||||
assert_eq!(merged.oldest, Some(now - Duration::from_secs(90)));
|
||||
assert!(merged.exhausted, "the round's other findings still land");
|
||||
|
||||
// A cursor already stored past `now` heals instead of staying deaf.
|
||||
let poisoned = ChannelCursor {
|
||||
newest: Some(now + Duration::from_secs(86_400)),
|
||||
..ChannelCursor::default()
|
||||
};
|
||||
|
||||
assert_eq!(clamped(poisoned, now).newest, Some(now));
|
||||
}
|
||||
|
||||
/// One public channel under a held root, and nothing else.
|
||||
fn state(channel: ChannelId) -> CommunityState {
|
||||
CommunityState {
|
||||
@@ -1123,6 +1185,7 @@ mod tests {
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
|
||||
@@ -458,6 +458,11 @@ impl Walk {
|
||||
}
|
||||
}
|
||||
|
||||
// The walk's floor, which the round resumes the older pass from.
|
||||
if let Some(oldest) = oldest {
|
||||
self.oldest = Some(self.oldest.map_or(oldest, |held| held.min(oldest)));
|
||||
}
|
||||
|
||||
match oldest {
|
||||
Some(oldest) if !oldest.is_zero() => self.cursor = Some(oldest - 1u64),
|
||||
Some(_) => self.bottom = true,
|
||||
@@ -599,6 +604,12 @@ mod tests {
|
||||
assert!(page.exhausted);
|
||||
assert!(!page.failed);
|
||||
assert_eq!(page.raw, 3);
|
||||
assert_eq!(page.newest, Some(Timestamp::from_secs(1_700_000_002)));
|
||||
assert_eq!(
|
||||
page.oldest,
|
||||
Some(Timestamp::from_secs(1_700_000_000)),
|
||||
"the walk reports its floor, which the older pass resumes below"
|
||||
);
|
||||
}
|
||||
|
||||
/// A wrap that reaches us and still will not open is history we cannot read,
|
||||
|
||||
@@ -26,6 +26,9 @@ pub use sync::*;
|
||||
|
||||
/// How long a burst of relay notifications is collected before it is folded.
|
||||
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);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
|
||||
@@ -139,6 +142,9 @@ pub struct CommunityRegistry {
|
||||
index: HashMap<CommunityId, Entity<Community>>,
|
||||
/// The plane set each community was last subscribed with
|
||||
synced: HashMap<CommunityId, SubscriptionKey>,
|
||||
/// When a relay last delivered something for a community,
|
||||
/// which is the only evidence the standing subscription is alive.
|
||||
last_event: HashMap<CommunityId, Instant>,
|
||||
/// One observer per tracked community, dropped on reset
|
||||
observers: HashMap<CommunityId, Subscription>,
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
@@ -197,6 +203,7 @@ impl CommunityRegistry {
|
||||
communities: Vec::new(),
|
||||
index: HashMap::new(),
|
||||
synced: HashMap::new(),
|
||||
last_event: HashMap::new(),
|
||||
observers: HashMap::new(),
|
||||
signal_tx: tx,
|
||||
signal_rx: rx,
|
||||
@@ -300,6 +307,7 @@ impl CommunityRegistry {
|
||||
self.communities.clear();
|
||||
self.index.clear();
|
||||
self.synced.clear();
|
||||
self.last_event.clear();
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
@@ -393,6 +401,7 @@ impl CommunityRegistry {
|
||||
for id in dropped {
|
||||
self.observers.remove(&id);
|
||||
self.synced.remove(&id);
|
||||
self.last_event.remove(&id);
|
||||
}
|
||||
|
||||
self.communities = communities
|
||||
@@ -428,11 +437,38 @@ impl CommunityRegistry {
|
||||
community.update(cx, |community, cx| community.rekey(cx));
|
||||
}
|
||||
|
||||
/// One scheduler pass: every community repairs itself if it has gone stale.
|
||||
/// One scheduler pass: every community repairs itself if it has gone stale,
|
||||
/// and any whose relays have gone quiet is re-subscribed.
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
for community in self.communities.clone() {
|
||||
community.update(cx, |community, cx| community.tick(cx));
|
||||
}
|
||||
self.rotate_quiet(cx);
|
||||
}
|
||||
|
||||
/// Re-issue the standing subscription of every community that has been quiet for `LIVE_ROTATE`
|
||||
fn rotate_quiet(&mut self, cx: &mut Context<Self>) {
|
||||
let now = Instant::now();
|
||||
let ids: Vec<CommunityId> = self.index.keys().copied().collect();
|
||||
let mut rotated = false;
|
||||
|
||||
for id in ids {
|
||||
let quiet = self
|
||||
.last_event
|
||||
.get(&id)
|
||||
.is_none_or(|at| now.duration_since(*at) >= LIVE_ROTATE);
|
||||
|
||||
if !quiet {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.synced.remove(&id);
|
||||
rotated = true;
|
||||
}
|
||||
|
||||
if rotated {
|
||||
self.sync_subscriptions(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-subscribe every community whose held planes moved.
|
||||
@@ -464,9 +500,12 @@ impl CommunityRegistry {
|
||||
};
|
||||
|
||||
let subscription = sync::subscription_id(&id);
|
||||
let filter = sync::live_filter(&planes, sync::live_window(&state));
|
||||
let filter = sync::live_filter(&planes, sync::live_window(&state, Timestamp::now()));
|
||||
let relays = key.relays().to_vec();
|
||||
|
||||
// A fresh REQ counts as evidence of life for this community.
|
||||
self.last_event.insert(id, Instant::now());
|
||||
|
||||
// The rekey watch is a second standing REQ over the same relays.
|
||||
let watch = match rekey::watches(&state) {
|
||||
Ok(watches) => rekey::watch_filter(&watches),
|
||||
@@ -563,7 +602,12 @@ impl CommunityRegistry {
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(signal) = rx.recv_async().await {
|
||||
match signal {
|
||||
Signal::Event(id) => this.update(cx, |this, cx| this.refresh(id, cx))?,
|
||||
Signal::Event(id) => this.update(cx, |this, cx| {
|
||||
// The only proof a community's subscription is still
|
||||
// delivering anything.
|
||||
this.last_event.insert(id, Instant::now());
|
||||
this.refresh(id, cx);
|
||||
})?,
|
||||
Signal::Rekey(id) => this.update(cx, |this, cx| this.rekey(id, cx))?,
|
||||
Signal::List => this.update(cx, |this, cx| this.load(cx))?,
|
||||
}
|
||||
|
||||
@@ -40,18 +40,25 @@ fn permissions(scope: RekeyScope) -> &'static [u64] {
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let mut watches = Vec::new();
|
||||
let roots = state.roots();
|
||||
|
||||
let next = Epoch(state.root_epoch.0 + 1);
|
||||
let group = cord06::rekey_group(RekeyScope::Base, &state.community_root, &state.id, next)?;
|
||||
for root in &roots {
|
||||
let next = Epoch(root.epoch.0 + 1);
|
||||
let group = cord06::rekey_group(RekeyScope::Base, &root.key, &state.id, next)?;
|
||||
watches.push(Watch {
|
||||
address: group.pk(),
|
||||
group,
|
||||
scope: RekeyScope::Base,
|
||||
epoch: next,
|
||||
});
|
||||
}
|
||||
|
||||
for channel in &state.channels {
|
||||
if !channel.private {
|
||||
@@ -153,6 +160,8 @@ pub struct BaseAdoption {
|
||||
/// The new Control Plane signing root, delivered to staff only.
|
||||
pub control_root: Option<[u8; 32]>,
|
||||
pub stepped: Vec<HeldKey>,
|
||||
/// The npubs whose rotations minted the epochs this walk passed through.
|
||||
pub refounders: BTreeSet<PublicKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -224,7 +233,7 @@ pub async fn adopt(
|
||||
let rotations = cord06::collect_rotations(&chunks);
|
||||
let mut adoptions = Adoptions::default();
|
||||
|
||||
let base = walk(
|
||||
let mut base = walk(
|
||||
RekeyScope::Base,
|
||||
permissions(RekeyScope::Base),
|
||||
state.root_epoch,
|
||||
@@ -238,6 +247,42 @@ pub async fn adopt(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut refounders: BTreeSet<PublicKey> = base
|
||||
.adopted
|
||||
.as_ref()
|
||||
.map(|adopted| adopted.refounders.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
for root in state.roots().into_iter().skip(1) {
|
||||
let prior = walk(
|
||||
RekeyScope::Base,
|
||||
permissions(RekeyScope::Base),
|
||||
root.epoch,
|
||||
root.key,
|
||||
state,
|
||||
roles,
|
||||
signer,
|
||||
me,
|
||||
&rotations,
|
||||
&published,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(adopted) = prior.adopted else {
|
||||
continue;
|
||||
};
|
||||
|
||||
refounders.extend(adopted.refounders.iter().copied());
|
||||
|
||||
if base
|
||||
.adopted
|
||||
.as_ref()
|
||||
.is_none_or(|held| adopted.epoch > held.epoch)
|
||||
{
|
||||
base.adopted = Some(adopted);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(step) = base.adopted {
|
||||
adoptions.base = Some(BaseAdoption {
|
||||
epoch: step.epoch,
|
||||
@@ -245,6 +290,7 @@ pub async fn adopt(
|
||||
control_pk: step.control_pk,
|
||||
control_root: step.control_root,
|
||||
stepped: step.stepped,
|
||||
refounders,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -552,6 +598,9 @@ struct Adopted {
|
||||
control_pk: Option<PublicKey>,
|
||||
control_root: Option<[u8; 32]>,
|
||||
stepped: Vec<HeldKey>,
|
||||
/// The rotator of every base epoch this walk passed through,
|
||||
/// which is who may seed the Guestbook snapshot of the epoch it minted.
|
||||
refounders: BTreeSet<PublicKey>,
|
||||
}
|
||||
|
||||
/// What one rotation offered this client, and when it was published.
|
||||
@@ -561,6 +610,7 @@ struct Delivery {
|
||||
control_pk: Option<PublicKey>,
|
||||
control_root: Option<[u8; 32]>,
|
||||
at_ms: u64,
|
||||
rotator: PublicKey,
|
||||
}
|
||||
|
||||
/// Walk a scope's rotations forward, one epoch at a time, off the key held.
|
||||
@@ -579,6 +629,7 @@ async fn walk(
|
||||
) -> Result<Step> {
|
||||
let mut step = Step::default();
|
||||
let mut stepped: Vec<HeldKey> = Vec::new();
|
||||
let mut refounders: BTreeSet<PublicKey> = BTreeSet::new();
|
||||
let ceiling = held_epoch.0 + REKEY_LOOKAHEAD;
|
||||
|
||||
loop {
|
||||
@@ -658,6 +709,7 @@ async fn walk(
|
||||
control_pk,
|
||||
control_root: delivered.control_root,
|
||||
at_ms,
|
||||
rotator: rotation.rotator,
|
||||
});
|
||||
} else if let Some(held) = delivery.as_mut() {
|
||||
held.at_ms = held.at_ms.min(at_ms);
|
||||
@@ -671,9 +723,6 @@ async fn walk(
|
||||
HeldKey {
|
||||
epoch: held_epoch,
|
||||
key: held_key,
|
||||
// The cutoff is a second-granular read boundary, so the
|
||||
// rotation's millisecond publish time narrows to the second
|
||||
// it landed in.
|
||||
retired_at: Some(Timestamp::from_secs(delivered.at_ms / 1000)),
|
||||
},
|
||||
);
|
||||
@@ -681,12 +730,17 @@ async fn walk(
|
||||
held_epoch = target;
|
||||
held_key = delivered.key;
|
||||
|
||||
if scope == RekeyScope::Base {
|
||||
refounders.insert(delivered.rotator);
|
||||
}
|
||||
|
||||
step.adopted = Some(Adopted {
|
||||
epoch: target,
|
||||
key: delivered.key,
|
||||
control_pk: delivered.control_pk,
|
||||
control_root: delivered.control_root,
|
||||
stepped: stepped.clone(),
|
||||
refounders: refounders.clone(),
|
||||
});
|
||||
|
||||
continue;
|
||||
@@ -739,7 +793,7 @@ mod tests {
|
||||
base_rekey_group_key, channel_rekey_group_key, control_signer_group_key,
|
||||
epoch_key_commitment,
|
||||
};
|
||||
use concord::state::ChannelKeyRef;
|
||||
use concord::state::{ChannelKeyRef, HeldRoot};
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
@@ -747,6 +801,7 @@ mod tests {
|
||||
const AT_MS: u64 = 1_700_000_000_000;
|
||||
const ROOT: [u8; 32] = [0x55; 32];
|
||||
const NEW_ROOT: [u8; 32] = [0x66; 32];
|
||||
const NEWER_ROOT: [u8; 32] = [0x77; 32];
|
||||
const CHANNEL_KEY: [u8; 32] = [0x07; 32];
|
||||
const NEW_CHANNEL_KEY: [u8; 32] = [0x08; 32];
|
||||
|
||||
@@ -780,6 +835,7 @@ mod tests {
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
@@ -901,6 +957,75 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// A complete base rotation off a retained root is adopted from it, which
|
||||
/// is the only way its epoch's refounder is ever learned: a Guestbook
|
||||
/// snapshot seeds members on that npub's authority alone (CORD-02 §5).
|
||||
#[test]
|
||||
fn a_rotation_past_a_retained_root_is_adopted_and_names_its_refounder() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let owner = Keys::generate();
|
||||
let me = Keys::generate();
|
||||
let id = CommunityId::from_bytes([0x42; 32]);
|
||||
let channel = ChannelId::from_bytes([0x9c; 32]);
|
||||
|
||||
// At epoch 1, with the root it stepped off retained.
|
||||
let mut state = state(owner.public_key(), id, channel);
|
||||
state.root_epoch = Epoch(1);
|
||||
state.community_root = NEW_ROOT;
|
||||
state.held_roots = vec![HeldRoot {
|
||||
epoch: Epoch(0),
|
||||
key: ROOT,
|
||||
control_pk: None,
|
||||
retired_at: None,
|
||||
}];
|
||||
|
||||
let blob = smol::block_on(build_blob(
|
||||
&owner,
|
||||
&me.public_key(),
|
||||
RekeyScope::Base,
|
||||
Epoch(2),
|
||||
&NEWER_ROOT,
|
||||
None,
|
||||
None,
|
||||
))
|
||||
.expect("builds");
|
||||
let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives");
|
||||
let wraps = smol::block_on(build_rekey_chunks(
|
||||
&owner,
|
||||
&group,
|
||||
RekeyScope::Base,
|
||||
Epoch(2),
|
||||
Epoch(1),
|
||||
&epoch_key_commitment(Epoch(1), &NEW_ROOT),
|
||||
&[blob],
|
||||
None,
|
||||
false,
|
||||
AT_MS / 1000,
|
||||
))
|
||||
.expect("builds");
|
||||
store(&client, &wraps).await;
|
||||
|
||||
let signer = UniversalSigner::new(me.clone());
|
||||
let adoptions = adopt(
|
||||
&client,
|
||||
&state,
|
||||
&CommunityRoles::default(),
|
||||
&signer,
|
||||
me.public_key(),
|
||||
)
|
||||
.await
|
||||
.expect("reads");
|
||||
|
||||
let base = adoptions.base.expect("adopted");
|
||||
assert_eq!(base.epoch, Epoch(2));
|
||||
assert_eq!(base.key, NEWER_ROOT);
|
||||
assert_eq!(base.stepped.len(), 1, "the root it stepped off");
|
||||
assert_eq!(base.stepped[0].epoch, Epoch(1));
|
||||
assert!(base.refounders.contains(&owner.public_key()));
|
||||
});
|
||||
}
|
||||
|
||||
/// A rotation off a key we do not hold is a fork: adoptable by nobody here.
|
||||
#[test]
|
||||
fn a_rotation_that_does_not_extend_the_held_key_is_never_adopted() {
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use concord::cord01::KIND_WRAP_EPHEMERAL;
|
||||
use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST};
|
||||
use concord::cord02::list::{CommunityList, JoinMaterial, KIND_COMMUNITY_LIST};
|
||||
use concord::cord02::{self, ControlFold, ImageRef};
|
||||
use concord::cord04::AuthorityCitation;
|
||||
use concord::cord04::roles::{Permissions, citation_ok};
|
||||
@@ -11,7 +11,7 @@ use concord::derive::{
|
||||
channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key,
|
||||
};
|
||||
use concord::state::{CommunityState, HeldKey, HeldRoot, list_entry};
|
||||
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||
use concord::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32};
|
||||
use gpui::AsyncApp;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::UniversalSigner;
|
||||
@@ -137,7 +137,7 @@ pub fn live_filter(planes: &[Plane], window: Window) -> Filter {
|
||||
}
|
||||
|
||||
/// The window a community's standing subscription opens with.
|
||||
pub fn live_window(state: &CommunityState) -> Window {
|
||||
pub fn live_window(state: &CommunityState, now: Timestamp) -> Window {
|
||||
let floor = state
|
||||
.channels
|
||||
.iter()
|
||||
@@ -146,7 +146,7 @@ pub fn live_window(state: &CommunityState) -> Window {
|
||||
|
||||
match floor {
|
||||
Some(floor) => Window {
|
||||
since: Some(floor - CURSOR_OVERLAP),
|
||||
since: Some(floor.min(now) - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
None => Window::default(),
|
||||
@@ -382,11 +382,13 @@ pub async fn load(
|
||||
}
|
||||
};
|
||||
|
||||
let state = match held.remove(&entry.community_id) {
|
||||
let mut state = match held.remove(&entry.community_id) {
|
||||
Some(materialized) => refresh(materialized, fresh),
|
||||
None => fresh,
|
||||
};
|
||||
|
||||
retain_join_root(&mut state, &entry.seed);
|
||||
|
||||
cache::save_state(client, &state).await?;
|
||||
held.insert(entry.community_id, state);
|
||||
}
|
||||
@@ -394,6 +396,46 @@ pub async fn load(
|
||||
Ok(held.into_values().collect())
|
||||
}
|
||||
|
||||
fn retain_join_root(state: &mut CommunityState, seed: &JoinMaterial) {
|
||||
if seed.root_epoch >= state.root_epoch {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(key) = decode_hex_32(&seed.community_root) else {
|
||||
return;
|
||||
};
|
||||
|
||||
retain_root(state, seed.root_epoch, key, seed.control_pk);
|
||||
}
|
||||
|
||||
/// Retain a root the community has rotated past, newest first.
|
||||
fn retain_root(
|
||||
state: &mut CommunityState,
|
||||
epoch: Epoch,
|
||||
key: [u8; 32],
|
||||
control_pk: Option<PublicKey>,
|
||||
) {
|
||||
if state.held_roots.iter().any(|root| root.epoch == epoch) {
|
||||
return;
|
||||
}
|
||||
|
||||
let at = state
|
||||
.held_roots
|
||||
.iter()
|
||||
.position(|root| root.epoch < epoch)
|
||||
.unwrap_or(state.held_roots.len());
|
||||
|
||||
state.held_roots.insert(
|
||||
at,
|
||||
HeldRoot {
|
||||
epoch,
|
||||
key,
|
||||
control_pk,
|
||||
retired_at: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn retired(list: &CommunityList, id: &CommunityId, added_at_ms: u64) -> bool {
|
||||
list.tombstones
|
||||
.iter()
|
||||
@@ -402,6 +444,12 @@ fn retired(list: &CommunityList, id: &CommunityId, added_at_ms: u64) -> bool {
|
||||
}
|
||||
|
||||
fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
|
||||
if fresh.root_epoch > held.root_epoch {
|
||||
let (epoch, key) = (held.root_epoch, held.community_root);
|
||||
let control_pk = held.control_pks.get(&epoch.0).copied();
|
||||
retain_root(&mut held, epoch, key, control_pk);
|
||||
}
|
||||
|
||||
held.owner = fresh.owner;
|
||||
held.owner_salt = fresh.owner_salt;
|
||||
held.community_root = fresh.community_root;
|
||||
@@ -621,7 +669,8 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
|
||||
};
|
||||
|
||||
let now_ms = Timestamp::now().as_secs().saturating_mul(1000);
|
||||
let coalesced = cord02::guestbook::coalesce(&guestbook_rumors, now_ms, None, can_kick);
|
||||
let coalesced =
|
||||
cord02::guestbook::coalesce(&guestbook_rumors, now_ms, &state.refounders, can_kick);
|
||||
|
||||
let mut members = cord02::guestbook::complete_memberlist(
|
||||
&coalesced,
|
||||
@@ -655,6 +704,8 @@ fn observe(observed: &mut BTreeMap<PublicKey, u64>, author: PublicKey, at_ms: u6
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
@@ -713,6 +764,7 @@ mod tests {
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
@@ -754,7 +806,9 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A cold subscription asks wide; a warm one resumes at the oldest held
|
||||
/// cursor, minus the overlap, so no channel's new region is skipped.
|
||||
/// cursor, minus the overlap, so no channel's new region is skipped. A floor
|
||||
/// the local clock has not reached is a peer's stamp and is clamped, or the
|
||||
/// subscription would ask from a region that is still in the future.
|
||||
#[test]
|
||||
fn the_live_window_is_wide_cold_and_resumes_at_the_oldest_cursor_warm() {
|
||||
let mut state = held(
|
||||
@@ -762,8 +816,9 @@ mod tests {
|
||||
Keys::generate().public_key(),
|
||||
);
|
||||
let channel = state.channels[0].id;
|
||||
let now = Timestamp::now();
|
||||
|
||||
assert_eq!(live_window(&state), Window::default());
|
||||
assert_eq!(live_window(&state, now), Window::default());
|
||||
|
||||
state.cursors.insert(
|
||||
channel,
|
||||
@@ -774,12 +829,30 @@ mod tests {
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
live_window(&state),
|
||||
live_window(&state, now),
|
||||
Window {
|
||||
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
}
|
||||
);
|
||||
|
||||
let ahead = now + Duration::from_secs(3_600);
|
||||
state.cursors.insert(
|
||||
channel,
|
||||
concord::state::ChannelCursor {
|
||||
newest: Some(ahead),
|
||||
oldest: Some(Timestamp::from_secs(1_000)),
|
||||
exhausted: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
live_window(&state, now),
|
||||
Window {
|
||||
since: Some(now - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
"a cursor stamped in the future must not open the REQ ahead of now"
|
||||
);
|
||||
}
|
||||
|
||||
fn metadata(name: &str) -> cord02::CommunityMetadata {
|
||||
@@ -813,6 +886,7 @@ mod tests {
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
@@ -832,6 +906,141 @@ mod tests {
|
||||
client.database().save_event(&event).await.expect("saves");
|
||||
}
|
||||
|
||||
/// A Refounding snapshot seeds members this client has never seen publish —
|
||||
/// but only on the authority of the npub whose rotation minted the epoch it
|
||||
/// seeds (CORD-02 §5), which is why the refounder is recorded at all.
|
||||
#[test]
|
||||
fn a_refounders_snapshot_seeds_the_members_it_names() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
let refounder = Keys::generate();
|
||||
let quiet = Keys::generate().public_key();
|
||||
|
||||
let created = create(&client, &signer, &metadata("coop"))
|
||||
.await
|
||||
.expect("creates");
|
||||
|
||||
// Past genesis, and past the rotation whose refounder this client
|
||||
// verified: genesis mints no epoch by rotation, so it has no
|
||||
// snapshot authority at all.
|
||||
let mut state = created;
|
||||
state.root_epoch = Epoch(1);
|
||||
state.refounders.insert(refounder.public_key());
|
||||
|
||||
let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)
|
||||
.expect("a guestbook plane");
|
||||
let chunks = cord02::guestbook::build_snapshot_chunks(
|
||||
refounder.public_key(),
|
||||
&[quiet],
|
||||
[0x77u8; 32],
|
||||
state.added_at_ms,
|
||||
);
|
||||
|
||||
for chunk in &chunks {
|
||||
let (wrap, _) = cord02::guestbook::seal_rumor(chunk, &group, &refounder)
|
||||
.await
|
||||
.expect("seals");
|
||||
client.database().save_event(&wrap).await.expect("saves");
|
||||
}
|
||||
|
||||
let seeded = fold(&client, &state)
|
||||
.await
|
||||
.expect("folds")
|
||||
.expect("a control plane");
|
||||
assert!(
|
||||
seeded.members.contains(&quiet),
|
||||
"the snapshot's members are the roster"
|
||||
);
|
||||
|
||||
// Without the rotation that minted the epoch, the same chunks seed
|
||||
// nobody: an unverifiable snapshot is not an authority.
|
||||
let mut unverified = state.clone();
|
||||
unverified.refounders.clear();
|
||||
|
||||
let folded = fold(&client, &unverified)
|
||||
.await
|
||||
.expect("folds")
|
||||
.expect("a control plane");
|
||||
assert!(!folded.members.contains(&quiet));
|
||||
});
|
||||
}
|
||||
|
||||
/// A List that has rotated on keeps the root of our join: the material is
|
||||
/// the community as we were given it, and the planes that root addressed
|
||||
/// stay readable only while it is held.
|
||||
#[test]
|
||||
fn a_list_that_moved_the_root_on_retains_the_root_of_our_join() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
|
||||
let joined = held(
|
||||
CommunityId::from_bytes([0x42; 32]),
|
||||
Keys::generate().public_key(),
|
||||
);
|
||||
let mut rotated = joined.clone();
|
||||
rotated.root_epoch = Epoch(2);
|
||||
rotated.community_root = [0x44; 32];
|
||||
|
||||
// The entry as a community that rotated twice since would carry it:
|
||||
// the join's material as the seed, the current material as current.
|
||||
let mut entry = list_entry(&rotated, "coop");
|
||||
entry.seed = list_entry(&joined, "coop").seed;
|
||||
entry.added_at = joined.added_at_ms;
|
||||
|
||||
let list = CommunityList::default().joined(entry);
|
||||
store_fragment(&client, &signer, &list).await;
|
||||
|
||||
let loaded = load(&client, &signer, keys.public_key())
|
||||
.await
|
||||
.expect("loads");
|
||||
let state = loaded
|
||||
.iter()
|
||||
.find(|state| state.id == joined.id)
|
||||
.expect("loaded");
|
||||
|
||||
assert_eq!(state.root_epoch, Epoch(2));
|
||||
assert_eq!(state.community_root, [0x44; 32]);
|
||||
assert_eq!(
|
||||
state
|
||||
.held_roots
|
||||
.iter()
|
||||
.map(|root| (root.epoch, root.key))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(joined.root_epoch, joined.community_root)]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The List discovering a root moves the community on without dropping the
|
||||
/// one held: only the current root is exchanged for the material's.
|
||||
#[test]
|
||||
fn a_list_material_at_a_newer_epoch_keeps_the_root_it_superseded() {
|
||||
let held_state = held(
|
||||
CommunityId::from_bytes([0x42; 32]),
|
||||
Keys::generate().public_key(),
|
||||
);
|
||||
let mut fresh = held_state.clone();
|
||||
fresh.root_epoch = Epoch(3);
|
||||
fresh.community_root = [0x55; 32];
|
||||
|
||||
let merged = refresh(held_state.clone(), fresh);
|
||||
|
||||
assert_eq!(merged.root_epoch, Epoch(3));
|
||||
assert_eq!(merged.community_root, [0x55; 32]);
|
||||
assert_eq!(
|
||||
merged
|
||||
.held_roots
|
||||
.iter()
|
||||
.map(|root| (root.epoch, root.key))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(held_state.root_epoch, held_state.community_root)]
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -1154,6 +1363,157 @@ mod tests {
|
||||
assert!(!retired.accepts(&event_at(1_001_000)));
|
||||
}
|
||||
|
||||
/// A public channel reads one plane per held root epoch, and the plane for
|
||||
/// the CURRENT root is the one the community writes to now.
|
||||
///
|
||||
/// A Refounding moves the community root to a new epoch and every public
|
||||
/// channel's plane with it (CORD-03 §1: a public channel's secret is the
|
||||
/// community root, at the root epoch). Material the channel carries from
|
||||
/// before the rotation names the old epoch, so a reader that trusts it asks
|
||||
/// a retired address forever: the channel shows everything written before
|
||||
/// the rotation and nothing after, while the control and guestbook planes —
|
||||
/// which derive from the roots directly — stay current.
|
||||
#[test]
|
||||
fn a_refounding_moves_a_public_channels_plane_to_the_new_root_epoch() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
|
||||
let mut state = create(&client, &signer, &metadata("coop"))
|
||||
.await
|
||||
.expect("creates");
|
||||
let channel = state.channels[0].id;
|
||||
let rotated_at = Timestamp::now();
|
||||
let prior_root = state.community_root;
|
||||
|
||||
// A Refounding: the root moves to epoch 1, the prior one is held for
|
||||
// history, and the channel's own material still names epoch 0.
|
||||
state.community_root = [0x5a; 32];
|
||||
state.root_epoch = Epoch(1);
|
||||
state.held_roots = vec![HeldRoot {
|
||||
epoch: Epoch(0),
|
||||
key: prior_root,
|
||||
control_pk: None,
|
||||
retired_at: Some(rotated_at),
|
||||
}];
|
||||
|
||||
assert_eq!(state.channels[0].epoch, Epoch(0));
|
||||
|
||||
let current = channel_group_key(&state.community_root, &channel, state.root_epoch)
|
||||
.expect("derives");
|
||||
let plane = planes(&state)
|
||||
.expect("planes")
|
||||
.into_iter()
|
||||
.find(|plane| plane.address == current.pk())
|
||||
.expect("the current root's channel plane is subscribed and read");
|
||||
|
||||
assert_eq!(plane.kind, PlaneKind::Channel(channel, Epoch(1)));
|
||||
assert!(
|
||||
plane.accepts(
|
||||
&EventBuilder::new(Kind::GiftWrap, "")
|
||||
.finalize(plane.group.keys())
|
||||
.expect("signs")
|
||||
)
|
||||
);
|
||||
|
||||
// And a message sealed at the current plane folds into the channel,
|
||||
// which is what the timeline reads.
|
||||
let rumor = concord::cord03::build_message(
|
||||
keys.public_key(),
|
||||
&channel,
|
||||
state.root_epoch,
|
||||
"after the refounding",
|
||||
None,
|
||||
rotated_at.as_secs().saturating_mul(1000),
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = concord::cord03::seal_rumor(&rumor, &plane.group, &signer, false)
|
||||
.await
|
||||
.expect("seals");
|
||||
client.database().save_event(&wrap).await.expect("saves");
|
||||
|
||||
let snapshot = fold(&client, &state)
|
||||
.await
|
||||
.expect("folds")
|
||||
.expect("a control plane");
|
||||
assert_eq!(snapshot.unreadable.get(&channel).copied(), None);
|
||||
|
||||
let cached = cache::query_rumors(
|
||||
&client,
|
||||
&channel,
|
||||
None,
|
||||
10,
|
||||
Some(&concord::cord03::ROW_KINDS),
|
||||
)
|
||||
.await
|
||||
.expect("reads");
|
||||
|
||||
assert_eq!(cached.len(), 1, "a message at the new epoch reads back");
|
||||
});
|
||||
}
|
||||
|
||||
/// A wrap a subscription delivered lands in the database and nowhere else.
|
||||
/// The fold is what turns it into a cached rumor, which is the only thing
|
||||
/// the timeline reads, so a live message depends on this step.
|
||||
#[test]
|
||||
fn a_fold_caches_a_channel_wrap_a_relay_delivered() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
|
||||
let created = create(&client, &signer, &metadata("coop"))
|
||||
.await
|
||||
.expect("creates");
|
||||
|
||||
let (channel, epoch, plane) = planes(&created)
|
||||
.expect("planes")
|
||||
.into_iter()
|
||||
.find_map(|plane| match plane.kind {
|
||||
PlaneKind::Channel(channel, epoch) => Some((channel, epoch, plane)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a channel plane");
|
||||
|
||||
let rumor = concord::cord03::build_message(
|
||||
keys.public_key(),
|
||||
&channel,
|
||||
epoch,
|
||||
"a live message",
|
||||
None,
|
||||
1_700_000_000_000,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = concord::cord03::seal_rumor(&rumor, &plane.group, &signer, false)
|
||||
.await
|
||||
.expect("seals");
|
||||
|
||||
// What the SDK does with a wrap a standing subscription delivered.
|
||||
client.database().save_event(&wrap).await.expect("saves");
|
||||
|
||||
let snapshot = fold(&client, &created)
|
||||
.await
|
||||
.expect("folds")
|
||||
.expect("a control plane");
|
||||
|
||||
assert_eq!(snapshot.unreadable.get(&channel).copied(), None);
|
||||
|
||||
let cached = cache::query_rumors(
|
||||
&client,
|
||||
&channel,
|
||||
None,
|
||||
10,
|
||||
Some(&concord::cord03::ROW_KINDS),
|
||||
)
|
||||
.await
|
||||
.expect("reads");
|
||||
|
||||
assert_eq!(cached.len(), 1, "the delivered wrap is cached as a rumor");
|
||||
assert_eq!(cached[0].pubkey, keys.public_key());
|
||||
});
|
||||
}
|
||||
|
||||
/// A wrap addressed to a held channel plane that will not open is counted, so
|
||||
/// the panel can tell a quiet room from one whose history it cannot read.
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -502,32 +502,7 @@ impl CommunityPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut index: HashMap<EventId, usize> = self
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, message)| (message.id, ix))
|
||||
.collect();
|
||||
|
||||
let mut added = Vec::new();
|
||||
|
||||
for message in messages {
|
||||
match index.get(&message.id).copied() {
|
||||
Some(ix) => self.rows[ix] = message,
|
||||
None => {
|
||||
index.insert(message.id, self.rows.len() + added.len());
|
||||
added.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !added.is_empty() {
|
||||
let at = self.item_count();
|
||||
let count = added.len();
|
||||
self.rows.extend(added);
|
||||
self.list_state.splice(at..at, count);
|
||||
}
|
||||
|
||||
self.merge(messages);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -537,22 +512,42 @@ impl CommunityPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
let known: HashSet<EventId> = self.rows.iter().map(|message| message.id).collect();
|
||||
let added: Vec<ChatMessage> = timeline
|
||||
.messages
|
||||
.into_iter()
|
||||
.filter(|message| !known.contains(&message.id))
|
||||
.collect();
|
||||
|
||||
self.has_more = timeline.has_more;
|
||||
|
||||
if !added.is_empty() {
|
||||
let count = added.len();
|
||||
self.rows.splice(0..0, added);
|
||||
self.list_state.splice(1..1, count);
|
||||
self.merge(timeline.messages);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
/// Fold read rows into what is on screen, keeping the list in time order.
|
||||
fn merge(&mut self, messages: Vec<ChatMessage>) {
|
||||
let mut shown: HashMap<EventId, usize> = self
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, message)| (message.id, ix))
|
||||
.collect();
|
||||
|
||||
let mut fresh = Vec::new();
|
||||
|
||||
for message in messages {
|
||||
match shown.get(&message.id).copied() {
|
||||
Some(ix) => self.rows[ix] = message,
|
||||
None => fresh.push(message),
|
||||
}
|
||||
}
|
||||
|
||||
for message in fresh {
|
||||
if shown.contains_key(&message.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let at = self
|
||||
.rows
|
||||
.partition_point(|row| (row.at_ms, row.id) <= (message.at_ms, message.id));
|
||||
|
||||
shown.insert(message.id, at);
|
||||
self.rows.insert(at, message);
|
||||
self.list_state.splice(at + 1..at + 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -567,9 +562,6 @@ impl CommunityPanel {
|
||||
return;
|
||||
};
|
||||
|
||||
// A rotation that excluded us moved the write plane out of reach, and the
|
||||
// community refuses to seal a message nobody could read. The notice above
|
||||
// the list is what says why.
|
||||
if let Some(notice) = self.notice(cx).filter(|notice| !notice.writable()) {
|
||||
window.push_notification(Notification::error(notice.to_string()).autohide(false), cx);
|
||||
return;
|
||||
|
||||
@@ -187,10 +187,15 @@ pub fn open(
|
||||
Ok((opened, rumor))
|
||||
}
|
||||
|
||||
/// Coalesce the guestbook flat: one final state per npub, the latest entry
|
||||
/// winning by millisecond time, ties broken by the lower rumor id.
|
||||
///
|
||||
/// `snapshot_authorities` are the npubs whose refounding is known to have minted an epoch this client reads.
|
||||
/// A snapshot chunk is honored only from one of them, and an empty set honors no snapshot at all.
|
||||
pub fn coalesce(
|
||||
rumors: &[GuestbookRumor],
|
||||
now_ms: u64,
|
||||
snapshot_authority: Option<&PublicKey>,
|
||||
snapshot_authorities: &BTreeSet<PublicKey>,
|
||||
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
|
||||
) -> BTreeMap<PublicKey, MemberState> {
|
||||
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
|
||||
@@ -250,7 +255,7 @@ pub fn coalesce(
|
||||
at_ms,
|
||||
..
|
||||
} => {
|
||||
if snapshot_authority != Some(refounder) {
|
||||
if !snapshot_authorities.contains(refounder) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -457,6 +462,11 @@ mod tests {
|
||||
CommunityId::from_bytes([0x11u8; 32])
|
||||
}
|
||||
|
||||
/// The refounders a fold is told about: a snapshot seeds members on theirs alone.
|
||||
fn refounders(keys: &[&Keys]) -> BTreeSet<PublicKey> {
|
||||
keys.iter().map(|keys| keys.public_key()).collect()
|
||||
}
|
||||
|
||||
fn group() -> GroupKey {
|
||||
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
|
||||
}
|
||||
@@ -533,7 +543,7 @@ mod tests {
|
||||
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
|
||||
};
|
||||
|
||||
let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick);
|
||||
let states = coalesce(&rumors, AT + 8_000, &refounders(&[&carol]), can_kick);
|
||||
|
||||
assert_eq!(
|
||||
states.get(&alice.public_key()),
|
||||
@@ -562,7 +572,7 @@ mod tests {
|
||||
|
||||
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
|
||||
assert_eq!(
|
||||
coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick),
|
||||
coalesce(&reversed, AT + 8_000, &refounders(&[&carol]), can_kick),
|
||||
states,
|
||||
"arrival order cannot change the fold"
|
||||
);
|
||||
@@ -649,7 +659,7 @@ mod tests {
|
||||
),
|
||||
];
|
||||
|
||||
let states = coalesce(&rumors, AT + 1_000, None, can_kick);
|
||||
let states = coalesce(&rumors, AT + 1_000, &BTreeSet::new(), can_kick);
|
||||
|
||||
assert_eq!(
|
||||
states.get(&kicked.public_key()),
|
||||
@@ -686,21 +696,21 @@ mod tests {
|
||||
)
|
||||
.remove(0);
|
||||
|
||||
for authority in [None, Some(refounder.public_key())] {
|
||||
for authority in [BTreeSet::new(), refounders(&[&refounder])] {
|
||||
let states = coalesce(
|
||||
&[
|
||||
publish(&by_refounder, &refounder),
|
||||
publish(&by_impostor, &impostor),
|
||||
],
|
||||
AT + 1_000,
|
||||
authority.as_ref(),
|
||||
&authority,
|
||||
|_, _, _| true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
states.contains_key(&seeded.public_key()),
|
||||
authority.is_some(),
|
||||
"only the epoch's refounder seeds, and there is no owner fallback"
|
||||
!authority.is_empty(),
|
||||
"only a known refounder seeds, and there is no owner fallback"
|
||||
);
|
||||
assert!(
|
||||
!states.contains_key(&smuggled.public_key()),
|
||||
@@ -724,11 +734,11 @@ mod tests {
|
||||
&member,
|
||||
);
|
||||
assert!(
|
||||
coalesce(&[future], AT, None, |_, _, _| true).is_empty(),
|
||||
coalesce(&[future], AT, &BTreeSet::new(), |_, _, _| true).is_empty(),
|
||||
"an entry more than an hour ahead is dropped"
|
||||
);
|
||||
assert_eq!(
|
||||
coalesce(&[horizon], AT, None, |_, _, _| true).len(),
|
||||
coalesce(&[horizon], AT, &BTreeSet::new(), |_, _, _| true).len(),
|
||||
1,
|
||||
"the horizon itself is skew, not forgery"
|
||||
);
|
||||
|
||||
@@ -7,5 +7,6 @@ pub mod state;
|
||||
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
|
||||
pub(crate) use types::Extra;
|
||||
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
|
||||
pub use utils::decode_hex_32;
|
||||
pub use utils::derive::{self, GroupKey};
|
||||
pub(crate) use utils::{decode_hex_32, decode_hex_lower, fill_random, random_32};
|
||||
pub(crate) use utils::{decode_hex_lower, fill_random, random_32};
|
||||
|
||||
@@ -22,8 +22,7 @@ pub const STATE_PREFIX: &str = "concord/";
|
||||
pub struct HeldKey {
|
||||
pub epoch: Epoch,
|
||||
pub key: [u8; 32],
|
||||
/// The publish time of the rotation that superseded this key: a wrap
|
||||
/// sealed under it later than this does not read.
|
||||
/// The publish time of the rotation that superseded this key.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retired_at: Option<Timestamp>,
|
||||
}
|
||||
@@ -130,12 +129,15 @@ pub struct CommunityState {
|
||||
skip_serializing_if = "BTreeMap::is_empty"
|
||||
)]
|
||||
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
|
||||
/// Root epochs superseded by a rotation we adopted, newest first.
|
||||
/// Root epochs the community has rotated past that this client still holds.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub held_roots: Vec<HeldRoot>,
|
||||
/// The epoch a channel rotation removed us at.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
|
||||
/// The npubs whose rotation minted an epoch of this community we verified.
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub refounders: BTreeSet<PublicKey>,
|
||||
/// The base epoch we were excluded at.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub removed_at: Option<Epoch>,
|
||||
@@ -219,6 +221,7 @@ impl CommunityState {
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
@@ -273,6 +276,7 @@ impl CommunityState {
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
@@ -317,12 +321,10 @@ impl CommunityState {
|
||||
return keys;
|
||||
}
|
||||
|
||||
// A public channel derives from the community root, so its history
|
||||
// spans every root epoch the rotation kept a floor for.
|
||||
self.roots()
|
||||
.into_iter()
|
||||
.map(|root| HeldKey {
|
||||
epoch: held.epoch,
|
||||
epoch: root.epoch,
|
||||
key: root.key,
|
||||
retired_at: root.retired_at,
|
||||
})
|
||||
@@ -499,6 +501,7 @@ mod tests {
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
|
||||
@@ -9,8 +9,10 @@ use serde::Serialize;
|
||||
|
||||
use crate::Extra;
|
||||
|
||||
/// Decode a 64-character lowercase-hex string into 32 bytes.
|
||||
///
|
||||
/// Uppercase and other non-canonical spellings are rejected.
|
||||
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
pub fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
decode_hex_lower::<32>(value)
|
||||
}
|
||||
|
||||
|
||||
@@ -836,6 +836,7 @@ fn channel_row(
|
||||
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
TreeRow::new(
|
||||
ElementId::Name(SharedString::from(format!(
|
||||
|
||||
@@ -45,10 +45,36 @@ wait, and it is reported rather than rendered as an empty channel.
|
||||
divided by a thousand on the way out. §10 types the time fields (`Timestamp`,
|
||||
`Epoch`) and leaves a millisecond only where a millisecond is real.
|
||||
|
||||
**Revision 4.** The six phases landed and the symptom was still there: a
|
||||
community showed its full history, its full roster, and never a new message. The
|
||||
audit that followed found the read path asking for addresses nobody writes to,
|
||||
plus two smaller reasons the same shape comes back (§11):
|
||||
|
||||
5. **A public channel's plane follows the ROOT epoch, not the epoch its material
|
||||
names.** `held_keys` derived every held root's plane at the *channel's*
|
||||
recorded epoch, and `channel_secret` sealed at it, so after a Refounding the
|
||||
current root's plane was asked for and written to at the retired epoch — an
|
||||
address no other client reads. The channel kept everything written before the
|
||||
rotation and received nothing after it, while the Control and Guestbook planes
|
||||
(which derive from the roots directly) stayed current: the roster looked
|
||||
complete while the room looked dead.
|
||||
6. **The walk never reported its floor.** `Walk::accept` computed the page's
|
||||
oldest wrap into a local and never into `Walk::oldest`, so every page reported
|
||||
`oldest: None`. The older pass had no point to resume below (scroll-up could
|
||||
only re-read the local cache), the bridge never ran (a burst larger than one
|
||||
page left a hole the cursor then sealed as covered), and `exhausted` could
|
||||
never be earned.
|
||||
7. **Two ways for the live path to go quiet are closed.** A cursor is clamped to
|
||||
the local clock, so a peer's future stamp cannot bound every later filter
|
||||
below a region that has not happened; and a community whose relays go silent
|
||||
re-issues its standing REQ, because a subscription that died without saying so
|
||||
is indistinguishable from a quiet one.
|
||||
|
||||
Read path today (phase 1 landed the walk, phase 2a moved each layer, phase 2b
|
||||
swapped the transport and put the pump in charge of settling pages, phase 3 added
|
||||
the rekey watch and held epochs, phase 4 made an empty or unreadable room say so,
|
||||
phase 5 typed the times they all compare):
|
||||
phase 5 typed the times they all compare, revision 4 fixed the plane a public
|
||||
channel is read at once its root has rotated):
|
||||
|
||||
```
|
||||
CommunityPanel::load community_ui/src/lib.rs:192
|
||||
@@ -109,11 +135,14 @@ scheduler community/src/lib.rs (one tick per
|
||||
| 12 | `cache::purge_expired` is never called, so expired rows only drop at fold time, never from disk. | fixed in phase 2b (`sync_round` sweeps the channel before it starts) | `community/src/community.rs` |
|
||||
| 13 | **Every relay and database operation lived in `concord`**: `fetch_page` installed its own subscriptions and read the client database, `cache_rumor`/`query_rumors`/`save_state`/`load_states` wrote and read it. Consequences: the pump could not see a page's REQ (so the panel's live view and the history path were two unrelated worlds), each page hand-rolled a per-relay notification loop, and the protocol crate could not be built or tested without a `Client`. | fixed in phase 2a (split into `concord/src/state.rs`, `community/src/cache.rs` and `community/src/history.rs`; the page REQ still waits on its own loop — §2 moves it into the pump) | `concord/src/state.rs`, `community/src/{cache,history}.rs` |
|
||||
| 14 | **The fold is O(history)**: `sync::fold` re-reads every wrap in the community's planes and NIP-44-opens each one on every inbound wrap, purely to cache channel rumors and observe their authors for the member list. The live REQ replays the plane on every start, so this happens on every app run and every burst of messages. | fixed in phase 2b (channel wraps are opened once, on the way in; `fold` reads already-cached authors and times from `cache::wrapper_index` instead of opening them again) | `community/src/sync.rs`, `community/src/cache.rs` |
|
||||
| 15 | **A public channel's plane was derived at the channel's recorded epoch for every held root**, so after a Refounding the current root's plane was subscribed, paged and sealed at the retired epoch: no live events, no new pages, and no cursor ever written for the channel — while the history of the prior epoch and the wholeControl and Guestbook planes still read. | fixed in revision 4 (§11) | `concord/src/state.rs` (`held_keys`), `community/src/community.rs` (`channel_secret`) |
|
||||
| 16 | **The walk never reported its floor** (`Walk::oldest` was computed into a local), so the older pass never ran, the bridge never healed a gap, and `exhausted` was never earned. | fixed in revision 4 (§11) | `community/src/history.rs` (`Walk::accept`) |
|
||||
|
||||
Findings 1-4 and 9-10 are the user-visible symptom and were phase 1; 13 is where
|
||||
that work landed in the wrong crate; 5-7 are why some channels look empty forever;
|
||||
8 is why a failure looks like an empty room; 11 and 14 are why the store and the
|
||||
fold get slower the fuller a community is.
|
||||
fold get slower the fuller a community is; 15-16 are why a rotated community reads
|
||||
as a complete archive that stopped receiving.
|
||||
|
||||
## What the reference client does
|
||||
|
||||
@@ -574,7 +603,12 @@ remaining audience, and rendering any of it is the moderation and
|
||||
community-management surface §8 keeps out of scope, so `Community::rotate` is an
|
||||
API with no caller in the app yet — and `Rewrite.recipients` is the caller's to
|
||||
name, because a private channel's audience is in each member's own invite and not
|
||||
in the local state.
|
||||
in the local state. **And the new epoch's roster is not seeded:** CORD-02 §5 has
|
||||
the refounder publish a snapshot of the present members into the Guestbook it
|
||||
just minted, which is the only way a client that joins *after* the rotation ever
|
||||
learns who was already there. `cord02::guestbook::build_snapshot_chunks` is
|
||||
implemented and unobliged; a refounding published from here would leave every
|
||||
later joiner with an inferred roster (§12c).
|
||||
|
||||
### 7. Honest states (phase 4) — **landed**
|
||||
|
||||
@@ -754,6 +788,231 @@ What landed:
|
||||
No behaviour changes with it: the walk paged the same regions before and after,
|
||||
because every millisecond it held was a second multiplied by a thousand.
|
||||
|
||||
### 11. A Refounding moves a public channel's plane (revision 4) — **landed**
|
||||
|
||||
The symptom that survived every phase: a channel showed its history and its room
|
||||
members, and never a new message — not while the panel was open, and not after
|
||||
reopening it, while another client was writing to the same channel. Three
|
||||
findings, in order of how much they explain.
|
||||
|
||||
#### 11a. The plane a public channel is read and written at
|
||||
|
||||
A public channel holds no key of its own: CORD-03 §1 derives its Chat Plane from
|
||||
the **community root**, at the **root epoch** — `channelGroupKey(root, channel,
|
||||
rootEpoch)`. A Refounding moves the root to a new epoch, so every public
|
||||
channel's plane moves with it. The reference therefore keeps one stream per held
|
||||
**root** epoch and writes to the newest (`channelsView`'s `rootStreams` with
|
||||
`current: rootStreams[0]`), which is also what makes history survive the
|
||||
rotation.
|
||||
|
||||
This code paired every held root with the channel's *recorded* epoch — the root
|
||||
epoch the channel's material was published at — and `channel_secret` sealed at
|
||||
the same value. At genesis the two agree, so nothing looked wrong; after a
|
||||
rotation they diverge, and every plane the client asked for was addressed at the
|
||||
retired epoch:
|
||||
|
||||
| Plane | Derived from | After a Refounding |
|
||||
| ----- | ------------ | ------------------ |
|
||||
| Channel (`channel_group_key`) | `held_keys` | current root **at the old epoch** — an address nobody writes |
|
||||
| Control (`control_group_key`) | `state.control_pks` epochs | current |
|
||||
| Guestbook (`guestbook_group_key`) | `roots()` epochs | current |
|
||||
|
||||
That shape is the whole report: the roster, the channel list and the old history
|
||||
were all right, the room received nothing, and because the newest pass came back
|
||||
empty the round read as failed, so no cursor was ever written for that channel.
|
||||
The community's own messages went to the same unreachable plane — readable by us,
|
||||
invisible to everyone else.
|
||||
|
||||
**Fixed:** `held_keys` pairs each root with **its own** epoch
|
||||
(`HeldKey { epoch: root.epoch, key: root.key, retired_at: root.retired_at }`), and
|
||||
`channel_secret` seals a public channel at
|
||||
`(state.root_epoch, state.community_root)` — the newest held root, the one the
|
||||
reference calls `current`. The two halves move together on purpose: reading where
|
||||
we write, and writing where the rest of the protocol reads, is the same statement.
|
||||
|
||||
#### 11b. The page's floor
|
||||
|
||||
`sync_round` uses `history::page`'s report three ways: the newest pass's `oldest`
|
||||
is where the older pass resumes (`resume = saved.oldest.or(newest.oldest)`), the newest
|
||||
pass's `oldest` is the top of the bridge (`Window::between(saved.newest, oldest)`),
|
||||
and a page that reached the bottom earns `exhausted` so later rounds can stop
|
||||
paging. `Walk::accept` computed that value into a local and never assigned
|
||||
`Walk::oldest`, so every page reported `oldest: None` and all three consequences
|
||||
followed at once: the older pass never ran (scrolling up could only ever re-read
|
||||
the local cache), the bridge never ran (a burst larger than one page left a hole
|
||||
that the next cursor advance then sealed as covered), and the bottom was never
|
||||
reached. The persisted state said so plainly — no cursor carried an `oldest`, and
|
||||
every `exhausted` was false.
|
||||
|
||||
**Fixed:** `accept` folds the page's floor into `Walk::oldest`.
|
||||
|
||||
#### 11c. Two ways for the live path to go quiet, closed
|
||||
|
||||
- **A cursor is clamped to the local clock.** A wrap stamped in the future — a
|
||||
peer's skewed clock, or a hostile stamp — used to become
|
||||
`ChannelCursor.newest`, and the cursor is persisted, so every later REQ for that
|
||||
channel would open with `since` ahead of the present. Relays apply `since` to
|
||||
live events as well as to the stored replay, so the channel goes deaf for as
|
||||
long as the stamp leads and no restart heals it. The reference clamps at exactly
|
||||
this point (`WireSync`'s `writeCursor`: an event stamped in the future "must not
|
||||
drag the cursor past `now`"). `clamped` bounds `newest` both on the way into the
|
||||
document and on the way into a filter, so a cursor already stored past `now`
|
||||
heals on the next round instead of staying deaf.
|
||||
- **The standing REQ is re-issued when a community goes quiet.** A relay can end
|
||||
a subscription without a reason the SDK acts on, and nothing here re-installed
|
||||
one whose plane set had not moved, so a community whose subscription died looked
|
||||
exactly like a quiet community until the next launch. The reference rotates
|
||||
every relay's REQ after 90s of silence for this reason ("never trust one
|
||||
subscription for long"); `CommunityRegistry::tick` now does the same, because an
|
||||
accepted REQ that yields nothing and a REQ that was never accepted look
|
||||
identical from here. The re-issue resumes from the current window, so the seam
|
||||
is replayed rather than lost.
|
||||
|
||||
What is deliberately still not here: a future-dated message is folded and shown
|
||||
rather than held out of the timeline until its time comes (the reference's
|
||||
`FUTURE_HOLD_MS`). The clamp already keeps such a stamp from bounding a filter;
|
||||
hiding it is a display decision this client has not made.
|
||||
|
||||
And one gap this revision leaves open, because it is a different symptom (depth,
|
||||
not freshness) and it cannot be closed honestly from what the code has at hand:
|
||||
`sync::refresh` overwrites `community_root`/`root_epoch` from the community
|
||||
list's material without retiring the root it replaces, and `held_roots` is
|
||||
populated only by `rekey::adopt`. A client that learns of a Refounding from a
|
||||
re-materialized list entry rather than from a rekey blob the watch delivered
|
||||
therefore loses the prior root, which is exactly the history the reference keeps
|
||||
in `heldRoots` ("every held epoch stays in the decode set"). The messages are
|
||||
still on screen — they are in the local cache — but paging *below* them against
|
||||
a relay needs that plane. Retiring a root correctly needs the rotation's publish
|
||||
time; a list entry carries only its own `added_at`, so the honest fix is for the
|
||||
re-materialization to carry the prior root the way an adoption does, not to guess
|
||||
a cutoff here. **Closed in revision 5 (§12c)**, on the same terms: a root the
|
||||
List moved past is retained as a key we were given, and the cutoff stays absent
|
||||
rather than guessed.
|
||||
|
||||
### 12. Two reports from one client (revision 5) — **landed**
|
||||
|
||||
The plane fix worked — history, the roster and new messages all arrived — and the
|
||||
same client then reported two things it could see: *"when the message list
|
||||
updates, the order isn't by timestamp anymore"*, and *"the member list still
|
||||
isn't showing full members like the other client"*. Two independent bugs, both
|
||||
confirmed against the client's own database before anything was changed.
|
||||
|
||||
#### 12a. Rows are placed where the fold puts them, not appended
|
||||
|
||||
The panel treated a read as newer than the rows on screen: new ids were appended
|
||||
to the end (`apply`) and the older page was spliced onto the front (`prepend`).
|
||||
Both hold only while every read is *newer than everything shown* or *older than
|
||||
everything shown* — and reads are neither. A catch-up round heals a gap by
|
||||
caching history sealed long ago, the bridge pass fills the region *above* the
|
||||
cursor's newest, and a message delivered late lands with its own (older) time. The
|
||||
client's own database shows the ground for it: the room held 1,176 cached rows, so
|
||||
every window read is a *selection* of its history, and a round finishing while the
|
||||
panel is open hands `apply` rows older than the newest row already on screen. The
|
||||
same read also reaches `apply` twice — once through `Updated` and once through
|
||||
`load_older`'s own re-read — so whether a page of history landed at the top or the
|
||||
bottom depended on which task ran first.
|
||||
|
||||
**Fixed:** one `merge` for both paths. Rows already shown keep their position (an
|
||||
edit replaces its row in place); a row that is new is inserted where the fold
|
||||
would have put it — `partition_point` over `(at_ms, id)` ascending, the exact
|
||||
order `Community::timeline` returns — and the list state is spliced at that index
|
||||
instead of at the end. The order the panel shows is now a function of the data,
|
||||
not of the order reads happened to land in.
|
||||
|
||||
One other lie about time went with it: `send` stamped `at_ms` as
|
||||
`Timestamp::now().as_secs() * 1000`, and a rumor's `ms` tag is the *remainder*
|
||||
within the second (`split_ms`), so every message this client sent carried `0` —
|
||||
placed at the start of its second, up to 999 ms before it was written, and before
|
||||
any message from another client in the same second. The reference stamps
|
||||
`Date.now()`; `now_ms()` does the same, with the clock error propagated rather
|
||||
than flattened.
|
||||
|
||||
#### 12b. A Refounding snapshot could never be honored
|
||||
|
||||
`sync::fold` called `guestbook::coalesce` with `None` as the snapshot authority,
|
||||
and `coalesce` drops every snapshot chunk whose refounder is not that authority —
|
||||
so **every** Refounding snapshot this client ever received was ignored, and the
|
||||
roster fell back to the members it could infer: authors seen publishing, granted
|
||||
npubs, and joins it had itself received.
|
||||
|
||||
The client's database shows exactly what that costs. Vector Community is at root
|
||||
epoch 9 with no retained roots; its epoch-9 Guestbook holds 13 wraps — 11 joins,
|
||||
1 leave, and **one snapshot naming 240 members**, authored by `d133ecb0…`. Folded
|
||||
with no authority the roster comes out at **41 members**; folded with that one
|
||||
snapshot honored it comes out at **247**. Nothing about the wraps is missing or
|
||||
unreadable — only the authority to believe them was.
|
||||
|
||||
**Fixed:** the refounder is recorded where the protocol states it and honored
|
||||
where CORD-02 §5 asks for it.
|
||||
|
||||
- `CommunityState.refounders` — the npubs whose rotation minted an epoch this
|
||||
client verified. `guestbook::coalesce` now takes that set (`&BTreeSet`) instead
|
||||
of an `Option`, which is also what the reference passes: the wire never says
|
||||
which epoch's stream carried a chunk, so the authorities are unioned and the
|
||||
residual (one held epoch's refounder accepted on another's snapshot) is
|
||||
documented there and here. An empty set honors no snapshot, and genesis — where
|
||||
no rotation minted anything — has no authority at all.
|
||||
- `rekey::walk` records the rotator of every base step it adopts
|
||||
(`Delivery.rotator` → `Adopted.refounders` → `BaseAdoption.refounders`), and
|
||||
`merge_adoptions` folds them into the state. A rotation is the only place a
|
||||
refounder is ever named, and it is verified the way an adoption already was:
|
||||
continuity from the key held, a blob for us, and the rotator's rank.
|
||||
|
||||
Which raised the question the client's own state answered: it holds epoch 9 but
|
||||
never adopted the 8 → 9 rotation (its `held_roots` is empty), because it learned
|
||||
of the refounding from a re-materialized List entry. A rotation can only be read
|
||||
from the root it stepped off, so the refounder of an epoch the client was away
|
||||
for was unreachable. **Fixed in §12c**, and the two fixes are one story: the
|
||||
refounder of the current epoch is recoverable precisely because the root before
|
||||
it is retained.
|
||||
|
||||
#### 12c. A superseded root is history, not a secret
|
||||
|
||||
Three points, one rule — a root the community has rotated past stays held:
|
||||
|
||||
- `sync::refresh` no longer overwrites `community_root`/`root_epoch` from the
|
||||
List's material without retiring the root it replaces, which is what lost the
|
||||
prior root in the first place (the gap §11 closed on paper).
|
||||
- `sync::load` likewise keeps the root the List's **seed** material names: the
|
||||
List carries the community as it was when *we* joined, so that root is a key
|
||||
this client was given. `merge_entry` already preserves the lowest-epoch
|
||||
material as the seed, so a client that joined three rotations ago still has
|
||||
three rotations' worth of read keys recoverable.
|
||||
- `rekey::watches` now watches the epoch after **every** held root, not only the
|
||||
current one, and `rekey::adopt` walks from every held root before it, keeping
|
||||
the furthest adoption (the epoch the community is actually on). That is what
|
||||
turns a retained root into a refounder: the 7 → 8 and 8 → 9 rotations are
|
||||
still verifiable from the roots they stepped off.
|
||||
|
||||
The retained roots pay for themselves a second time: `held_keys` derives channel
|
||||
planes from them, so public-channel history *below* the local cache becomes
|
||||
pageable again for a rotated community — the depth gap §11 named, not just the
|
||||
refounder.
|
||||
|
||||
What is honestly still missing, in the order it matters:
|
||||
|
||||
- **Recovery needs the old rotation chunks to still be on a relay.** A refounder
|
||||
is only learnable from the rotation that named it; if the relays have dropped
|
||||
the chunk for the epoch a client is on, the seeded members stay unknown until
|
||||
the next Refounding — the client cannot verify a snapshot it has no rotation
|
||||
for, and accepting one from an unverified npub would let any member inject
|
||||
arbitrary npubs into everyone's roster.
|
||||
- **A Refounding this client publishes seeds nobody.** `build_snapshot_chunks`
|
||||
exists and the reference publishes one at every refounding ("present members
|
||||
only, chunked at 400"); `Community::rotate` does not yet. It has no caller, so
|
||||
nothing regresses today, but the first client that refounds without it leaves
|
||||
every later joiner with an inferred roster.
|
||||
- **`complete_memberlist` is still given no ban times.** The reference passes the
|
||||
Control Plane's authorized ban history so that activity *before* a ban does not
|
||||
resurface as membership after an unban; `fold_control` exposes the Banlist as a
|
||||
set and no times, so an unbanned member's older activity counts as present here.
|
||||
Nobody is lost by it — the failure mode is a member shown who left — and it is
|
||||
the fold in `concord` that would have to grow the times.
|
||||
- **The panel's order is untested.** The merge is a few lines and the crate has
|
||||
no harness for a panel; the invariant it restores is the one `Community::timeline`
|
||||
already promises, and it is asserted here by reading it against the fold, not
|
||||
by a test.
|
||||
|
||||
## Order of work
|
||||
|
||||
### Phase 2a — move the code (no behaviour change) — **landed**
|
||||
@@ -945,8 +1204,45 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
||||
`failed` and the next round re-asks the same region; `until` is exclusive and
|
||||
the walk terminates; a channel that already holds its newest page still pages
|
||||
older history; the bridge heals a hole; history pages across a rekey using
|
||||
retained prior keys. (Phase 1 landed the `Walk` half; the page-REQ half needs a
|
||||
relay.)
|
||||
retained prior keys; and the walk reports the floor the older pass resumes from
|
||||
— **landed in revision 4** (`a_walk_pages_back_across_a_rekey` asserts
|
||||
`page.oldest`, the half `sync_round` resumes from). (The page-REQ half still
|
||||
needs a relay.)
|
||||
- A Refounding's plane — **landed in revision 4**
|
||||
(`sync::tests::a_refounding_moves_a_public_channels_plane_to_the_new_root_epoch`):
|
||||
with the root at epoch 1, the prior root held, and the channel's material still
|
||||
naming epoch 0, the current root's channel plane is the one subscribed and read,
|
||||
and a message sealed at it folds into the channel. Before the fix the plane was
|
||||
derived at epoch 0 and the test failed on the missing plane.
|
||||
- A delivered wrap — **landed in revision 4**
|
||||
(`sync::tests::a_fold_caches_a_channel_wrap_a_relay_delivered`): a wrap saved
|
||||
into the database (what the SDK does with what a subscription delivered) and
|
||||
nothing else is opened and cached by the next fold, which is the step a live
|
||||
message depends on. Its `unreadable` counterpart was already covered.
|
||||
- The cursor's clock — **landed in revision 4**
|
||||
(`a_future_stamp_cannot_push_a_cursor_past_now`): a round's future stamp is
|
||||
clamped to `now` while its other findings land, and a cursor already stored past
|
||||
`now` heals; `sync::live_window` starts at `now - CURSOR_OVERLAP` for such a
|
||||
cursor instead of opening the REQ ahead of the present.
|
||||
- The roster's authority — **landed in revision 5**
|
||||
(`sync::tests::a_refounders_snapshot_seeds_the_members_it_names`): a snapshot
|
||||
sealed to the current epoch's Guestbook seeds a member who has never been seen
|
||||
publishing, and the same wraps seed nobody once the refounder is not in
|
||||
`refounders` — the authority is the rotation, never the author alone.
|
||||
- A rotation off a retained root — **landed in revision 5**
|
||||
(`rekey::tests::a_rotation_past_a_retained_root_is_adopted_and_names_its_refounder`):
|
||||
with the root before it retained, a rotation into epoch 2 is adopted from epoch
|
||||
1 and its rotator comes back as the epoch's refounder. The unit-half of §12c's
|
||||
recovery: what still needs a relay is whether the old chunk is *still there* to
|
||||
be read at all.
|
||||
- Retaining what was rotated past — **landed in revision 5**
|
||||
(`sync::tests::a_list_material_at_a_newer_epoch_keeps_the_root_it_superseded`
|
||||
and `a_list_that_moved_the_root_on_retains_the_root_of_our_join`): material at a
|
||||
newer epoch replaces the current root and retires the one it moved past, and a
|
||||
List whose seed names our join keeps that root held.
|
||||
- The page a rotated community's history sits on — the same retention is what
|
||||
`a_walk_pages_back_across_a_rekey` pages across, and revision 5 is what makes a
|
||||
*List*-learned refounding leave those keys held, not only a blob-learned one.
|
||||
- The pump — **landed in 2b** (`community/src/lib.rs` tests): an
|
||||
`EndOfStoredEvents(id)` settles exactly the page that owns `id`; a non-auth
|
||||
CLOSED settles its relay as refused; an `auth-required` CLOSED settles nothing;
|
||||
@@ -997,7 +1293,10 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
||||
- The scheduler — **landed in phase 4** structurally: `due()` is false inside
|
||||
`MIN_ROUND_INTERVAL` after a round, `stale()` requires a recorded round older
|
||||
than `STALE_AFTER`, and `tick` acts only on the active channel. Driving real
|
||||
time needs the same harness.
|
||||
time needs the same harness. Revision 4 added the rotation of a quiet
|
||||
community's standing REQ to the same pass, which is structural in the same way:
|
||||
what it needs to be observed is a relay that drops a subscription without
|
||||
saying so.
|
||||
- The fold: a new live wrap costs one decrypt, and a fold over a community with
|
||||
5,000 cached rows does not re-open them. (The skip is in place and structurally
|
||||
tested by `wrapper_index`; counting decrypts needs a harness.)
|
||||
@@ -1006,7 +1305,9 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
||||
- GPUI (`TestAppContext`): prepending older rows preserves the scroll anchor; a
|
||||
live message does not scroll a reader who is scrolled up; `has_more == false`
|
||||
disables the load-older row; the panel's notice precedence; `due()`/`stale()`
|
||||
under a driven clock. (No GPUI test harness exists in the repo yet.)
|
||||
under a driven clock; and — added by revision 5 — that a read carrying history
|
||||
older than the last row lands *above* it instead of at the end. (No GPUI test
|
||||
harness exists in the repo yet, so the merge is asserted by reading it.)
|
||||
- Manual runs: two accounts, a channel with more than 200 messages, one account
|
||||
offline long enough to miss a full page, one private channel, one rekey, and one
|
||||
run with a relay stopped so the notice and its retry are visible. The acceptance
|
||||
|
||||
@@ -175,8 +175,11 @@ let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false).await
|
||||
client.send_event(&wrap).to(&relays).await?;
|
||||
```
|
||||
|
||||
- `epoch` is the channel's current epoch (`state.channels` carries it). A private
|
||||
channel derives from its own key instead of `community_root`.
|
||||
- `epoch` is the **root** epoch for a public channel: its plane is
|
||||
`channel_group_key(community_root, channel, root_epoch)`, so a Refounding moves
|
||||
it along with the root. A private channel passes its own current channel epoch
|
||||
(`state.channels` carries it) and derives from its own key instead of
|
||||
`community_root`.
|
||||
- `timer` is `control.community.message_expiration`; pass `None` when it is off.
|
||||
The builder attaches the NIP-40 tag and `seal_rumor` mirrors it onto the wrap,
|
||||
so relays drop the ciphertext too.
|
||||
|
||||
Reference in New Issue
Block a user