.
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
|
||||
.cursors
|
||||
.get(&channel)
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let saved = clamped(
|
||||
self.state
|
||||
.cursors
|
||||
.get(&channel)
|
||||
.copied()
|
||||
.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))?,
|
||||
}
|
||||
|
||||
+138
-13
@@ -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)?;
|
||||
watches.push(Watch {
|
||||
address: group.pk(),
|
||||
group,
|
||||
scope: RekeyScope::Base,
|
||||
epoch: 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))
|
||||
self.has_more = timeline.has_more;
|
||||
self.merge(timeline.messages);
|
||||
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();
|
||||
|
||||
self.has_more = timeline.has_more;
|
||||
let mut fresh = Vec::new();
|
||||
|
||||
if !added.is_empty() {
|
||||
let count = added.len();
|
||||
self.rows.splice(0..0, added);
|
||||
self.list_state.splice(1..1, count);
|
||||
for message in messages {
|
||||
match shown.get(&message.id).copied() {
|
||||
Some(ix) => self.rows[ix] = message,
|
||||
None => fresh.push(message),
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
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!(
|
||||
|
||||
Reference in New Issue
Block a user