This commit is contained in:
2026-09-22 15:30:12 +07:00
parent 04fb70e657
commit 28f4c1596d
9 changed files with 1431 additions and 80 deletions
+4
View File
@@ -262,6 +262,10 @@ mod tests {
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: 7,
};
+172 -10
View File
@@ -6,7 +6,7 @@ use concord::cord02::{ControlFold, ImageRef};
use concord::cord03::{self, ChatMessage, ReplyRef};
use concord::cord04::roles::{Permissions, citation_ok};
use concord::derive::channel_group_key;
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState};
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot};
use concord::{ChannelId, CommunityId, Epoch};
use gpui::{App, AppContext, Context, EventEmitter, Task};
use nostr_sdk::prelude::*;
@@ -14,6 +14,7 @@ use state::NostrRegistry;
use crate::cache;
use crate::history::{self, PageRegistry, Window, WrapPage};
use crate::rekey::{self, Adoptions};
use crate::sync::{self, Snapshot};
/// Wraps one relay returns for one page request.
@@ -68,6 +69,7 @@ struct Round {
pub struct SubscriptionKey {
control_pks: BTreeMap<u64, PublicKey>,
channels: Vec<(ChannelId, Epoch, bool)>,
roots: Vec<(u64, [u8; 32])>,
relays: Vec<RelayUrl>,
}
@@ -80,6 +82,11 @@ impl SubscriptionKey {
.iter()
.map(|channel| (channel.id, channel.epoch, channel.private))
.collect(),
roots: state
.roots()
.into_iter()
.map(|root| (root.epoch.0, root.key))
.collect(),
relays: state.relays.clone(),
}
}
@@ -113,6 +120,8 @@ pub struct Community {
banner_task: Option<Task<Result<()>>>,
rounds: HashMap<ChannelId, Round>,
pages: PageRegistry,
rekey_task: Option<Task<Result<()>>>,
rekey_dirty: bool,
}
impl EventEmitter<CommunityEvent> for Community {}
@@ -134,6 +143,8 @@ impl Community {
banner_task: None,
rounds: HashMap::new(),
pages,
rekey_task: None,
rekey_dirty: false,
}
}
@@ -190,6 +201,17 @@ impl Community {
&self.members
}
/// The base epoch a rotation excluded us at: readable history, no writes.
pub fn removed_at(&self) -> Option<Epoch> {
self.state.removed_at
}
/// A complete rotation ahead of our epoch predates our join and
/// carries no blob for us, so the invite landed us on a superseded epoch.
pub fn stranded(&self) -> bool {
self.state.stranded
}
pub fn channels(&self) -> &[ChannelKeyRef] {
&self.state.channels
}
@@ -198,7 +220,7 @@ impl Community {
SubscriptionKey::of(&self.state)
}
/// A public channel derives its plane from the community root; a private one uses its granted key.
/// A public channel derives its write plane from the community root.
fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> {
let held = self
.state
@@ -206,16 +228,13 @@ impl Community {
.iter()
.find(|held| held.id == *channel)?;
if held.private {
return held.key.map(|key| (held.epoch, key));
}
Some((held.epoch, self.state.community_root))
held.current()
.or_else(|| (!held.private).then_some((held.epoch, self.state.community_root)))
}
/// Every secret the client holds for a channel, newest epoch first.
fn held_keys(&self, channel: &ChannelId) -> Vec<(Epoch, [u8; 32])> {
self.channel_secret(channel).into_iter().collect()
fn held_keys(&self, channel: &ChannelId) -> Vec<HeldKey> {
self.state.held_keys(channel)
}
pub fn sync_channel(
@@ -493,6 +512,149 @@ impl Community {
self.state = state;
}
/// Adopt whatever the rekey watch has delivered, then re-page what it moved.
pub fn rekey(&mut self, cx: &mut Context<Self>) {
if self.rekey_task.is_some() {
self.rekey_dirty = true;
return;
}
let nostr = NostrRegistry::global(cx);
let Some(me) = nostr.read(cx).current_user() else {
return;
};
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
let state = self.state.clone();
let roles = self.control.roles.clone();
let task =
cx.background_spawn(
async move { rekey::adopt(&client, &state, &roles, &signer, me).await },
);
self.rekey_task = Some(cx.spawn(async move |this, cx| {
let result = task.await;
this.update(cx, |this, cx| this.apply_rekey(result, cx))?;
Ok(())
}));
}
fn apply_rekey(&mut self, result: Result<Adoptions>, cx: &mut Context<Self>) {
self.rekey_task = None;
match result {
Ok(adoptions) if !adoptions.is_empty() => self.merge_adoptions(adoptions, cx),
Ok(_) => {}
Err(error) => cx.emit(CommunityEvent::Error(error.to_string())),
}
if self.rekey_dirty {
self.rekey_dirty = false;
self.rekey(cx);
}
}
/// Fold an adoption into the held state, persist it, and re-page what moved.
fn merge_adoptions(&mut self, adoptions: Adoptions, cx: &mut Context<Self>) {
let mut touched: Vec<ChannelId> = Vec::new();
if let Some(base) = adoptions.base {
let mut held = Vec::with_capacity(base.stepped.len() + self.state.held_roots.len());
for key in base.stepped {
held.push(HeldRoot {
epoch: key.epoch,
key: key.key,
control_pk: self.state.control_pks.get(&key.epoch.0).copied(),
retired_at: key.retired_at,
});
}
held.extend(self.state.held_roots.iter().copied());
self.state.held_roots = held;
if let Some(control_pk) = base.control_pk {
self.state.control_pks.insert(base.epoch.0, control_pk);
}
self.state.community_root = base.key;
self.state.root_epoch = base.epoch;
self.state.removed_at = None;
self.state.stranded = false;
// Every channel's plane moved with the root.
touched.extend(self.state.channels.iter().map(|channel| channel.id));
}
for channel in adoptions.channels {
let Some(held) = self
.state
.channels
.iter_mut()
.find(|held| held.id == channel.channel)
else {
continue;
};
// `stepped` carries the key held before the walk plus every epoch it
// passed through, each with the cutoff its superseding rotation set.
for key in channel.stepped {
if !held.priors.iter().any(|prior| prior.epoch == key.epoch) {
held.priors.push(key);
}
}
held.key = Some(channel.key);
held.epoch = channel.epoch;
held.private = true;
touched.push(channel.channel);
}
for (channel, epoch) in adoptions.cuts {
self.state.channels.retain(|held| held.id != channel);
self.state.channel_cuts.insert(channel, epoch.0);
self.state.cursors.remove(&channel);
}
if let Some(epoch) = adoptions.removed_at {
self.state.removed_at = Some(epoch);
}
if adoptions.stranded {
self.state.stranded = true;
}
// A rotation re-opens the region its planes now cover: an exhausted
// verdict earned under the old keys cannot be trusted under the new.
for channel in &touched {
if let Some(cursor) = self.state.cursors.get_mut(channel) {
cursor.exhausted = false;
}
}
self.persist(cx);
cx.notify();
cx.emit(CommunityEvent::Updated(self.state.id));
let Some(channel) = self
.active
.or_else(|| self.state.channels.first().map(|channel| channel.id))
else {
return;
};
let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx);
cx.spawn(async move |_this, _cx| {
if let Err(error) = catch_up.await {
log::warn!("community: the catch-up after a rekey failed: {error}");
}
})
.detach();
}
/// Rebuilds the community from the wraps in the local database.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh_task.is_some() {
@@ -609,7 +771,7 @@ async fn sync_round(
client: &Client,
pages: &PageRegistry,
channel: &ChannelId,
held: &[(Epoch, [u8; 32])],
held: &[HeldKey],
relays: &[RelayUrl],
saved: ChannelCursor,
intent: Intent,
+43 -12
View File
@@ -6,9 +6,10 @@ use std::time::{Duration, Instant};
use anyhow::Result;
use concord::cord01::KIND_WRAP_EPHEMERAL;
use concord::cord03::{self, ChatRumor, plane_keys};
use concord::state::ChannelCursor;
use concord::{ChannelId, Epoch};
use concord::cord03::{self, ChatRumor};
use concord::derive::channel_group_key;
use concord::state::{ChannelCursor, HeldKey};
use concord::{ChannelId, GroupKey};
use futures::future::{Either, select};
use nostr_sdk::prelude::*;
@@ -149,13 +150,16 @@ pub async fn page(
client: &Client,
pages: &PageRegistry,
channel: &ChannelId,
held: &[(Epoch, [u8; 32])],
held: &[HeldKey],
relays: &[RelayUrl],
window: Window,
max_pages: usize,
limit: usize,
) -> Result<WrapPage> {
let planes = plane_keys(held, channel)?;
let planes: Vec<(HeldKey, GroupKey)> = held
.iter()
.map(|key| Ok((*key, channel_group_key(&key.key, channel, key.epoch)?)))
.collect::<Result<Vec<_>>>()?;
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
if authors.is_empty() || relays.is_empty() || limit == 0 {
@@ -201,12 +205,20 @@ pub async fn page(
let answered = client.database().query(filter).await?;
for wrap in walk.accept(answered, limit) {
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
let Some((held, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
else {
continue;
};
let Ok((stream, rumor)) = cord03::open(&wrap, group, channel, *epoch) else {
// A retired key reads only what was sealed before its rotation published.
if held
.retired_at
.is_some_and(|retired| wrap.created_at.as_secs() > retired)
{
continue;
}
let Ok((stream, rumor)) = cord03::open(&wrap, group, channel, held.epoch) else {
continue;
};
@@ -332,7 +344,6 @@ fn history_subscription() -> SubscriptionId {
SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed)))
}
/// Await `future`, giving up after `limit`.
async fn within<F>(limit: Duration, future: F) -> Option<F::Output>
where
F: Future,
@@ -460,6 +471,7 @@ impl Walk {
mod tests {
use std::cmp::Reverse;
use concord::Epoch;
use concord::cord03::{build_message, seal_rumor};
use concord::derive::channel_group_key;
@@ -492,8 +504,27 @@ mod tests {
fn a_walk_pages_back_across_a_rekey() {
let channel = ChannelId::from_bytes([0x9cu8; 32]);
let author = Keys::generate();
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
let planes = plane_keys(&held, &channel).expect("derives");
let held = [
HeldKey {
epoch: Epoch(0),
key: SECRET,
retired_at: None,
},
HeldKey {
epoch: Epoch(1),
key: NEXT_SECRET,
retired_at: None,
},
];
let planes: Vec<(HeldKey, GroupKey)> = held
.iter()
.map(|key| {
(
*key,
channel_group_key(&key.key, &channel, key.epoch).expect("derives"),
)
})
.collect();
// Three messages a second apart: a page boundary falls between each.
let base = 1_700_000_000_000;
@@ -530,13 +561,13 @@ mod tests {
let page = serve_page(&relay, walk.region(), 2);
for wrap in walk.accept(page, 2) {
let Some((epoch, group)) =
let Some((held, group)) =
planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
else {
continue;
};
let (_, rumor) = cord03::open(&wrap, group, &channel, *epoch).expect("opens");
let (_, rumor) = cord03::open(&wrap, group, &channel, held.epoch).expect("opens");
found.push(rumor);
}
}
+101 -9
View File
@@ -13,10 +13,12 @@ use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use crate::history::{PageRegistry, Settled, auth_required};
use crate::rekey::WatchRegistry;
pub mod cache;
mod community;
pub mod history;
mod rekey;
mod sync;
pub use community::*;
@@ -37,6 +39,7 @@ impl Global for GlobalCommunityRegistry {}
enum Signal {
Event(CommunityId),
List,
Rekey(CommunityId),
}
/// Which standing subscription an id belongs to.
@@ -59,6 +62,7 @@ fn route_of(id: &SubscriptionId) -> Option<Route> {
struct Batch {
list: bool,
communities: BTreeSet<CommunityId>,
rekeys: BTreeSet<CommunityId>,
}
/// Whether the pump should keep listening.
@@ -69,17 +73,25 @@ enum Flow {
}
/// Fold one notification into the window, or settle a page it belongs to.
fn route(notification: ClientNotification, pages: &PageRegistry, batch: &mut Batch) -> Flow {
fn route(
notification: ClientNotification,
pages: &PageRegistry,
watches: &WatchRegistry,
batch: &mut Batch,
) -> Flow {
match notification {
ClientNotification::Event {
subscription_id, ..
} => match route_of(&subscription_id) {
Some(Route::List) => batch.list = true,
// Only the database is told here; the fold reads it when the window closes.
Some(Route::Community(id)) => {
batch.communities.insert(id);
}
None => {}
None => {
if let Some(id) = watches.community_of(&subscription_id) {
batch.rekeys.insert(id);
}
}
},
ClientNotification::Message { relay_url, message } => match *message {
RelayMessage::EndOfStoredEvents(id) => {
@@ -109,6 +121,10 @@ async fn flush(tx: &flume::Sender<Signal>, batch: &mut Batch) -> Result<()> {
tx.send_async(Signal::Event(id)).await?;
}
for id in std::mem::take(&mut batch.rekeys) {
tx.send_async(Signal::Rekey(id)).await?;
}
if std::mem::take(&mut batch.list) {
tx.send_async(Signal::List).await?;
}
@@ -129,6 +145,8 @@ pub struct CommunityRegistry {
signal_rx: flume::Receiver<Signal>,
/// The page subscriptions in flight, shared with the notification pump.
pages: PageRegistry,
/// The rekey watch subscriptions, resolved to their community.
watches: WatchRegistry,
tasks: SmallVec<[Task<Result<()>>; 2]>,
/// Notification listener task (cancelled on signer change)
notification_listener: Option<Task<Result<()>>>,
@@ -181,6 +199,7 @@ impl CommunityRegistry {
signal_tx: tx,
signal_rx: rx,
pages: PageRegistry::default(),
watches: WatchRegistry::default(),
tasks: smallvec![],
notification_listener: None,
signal_consumer: None,
@@ -256,6 +275,7 @@ impl CommunityRegistry {
self.tasks.clear();
self.observers.clear();
self.pages.clear();
self.watches.clear();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -264,9 +284,11 @@ impl CommunityRegistry {
for id in ids {
let client = client.clone();
let subscription = sync::subscription_id(&id);
let rekey = rekey::subscription_id(&id);
self.tasks.push(cx.background_spawn(async move {
client.unsubscribe(&subscription).await?;
client.unsubscribe(&rekey).await?;
Ok(())
}));
}
@@ -274,6 +296,7 @@ impl CommunityRegistry {
self.communities.clear();
self.index.clear();
self.synced.clear();
cx.notify();
}
@@ -392,6 +415,15 @@ impl CommunityRegistry {
community.update(cx, |community, cx| community.refresh(cx));
}
/// Adopt whatever a community's rekey watch has delivered.
fn rekey(&mut self, id: CommunityId, cx: &mut Context<Self>) {
let Some(community) = self.index.get(&id).cloned() else {
return;
};
community.update(cx, |community, cx| community.rekey(cx));
}
/// Re-subscribe every community whose held planes moved.
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -424,6 +456,18 @@ impl CommunityRegistry {
let filter = sync::live_filter(&planes, sync::live_window(&state));
let relays = key.relays().to_vec();
// The rekey watch is a second standing REQ over the same relays.
let watch = match rekey::watches(&state) {
Ok(watches) => rekey::watch_filter(&watches),
Err(error) => {
cx.emit(CommunityEvent::Error(error.to_string()));
continue;
}
};
let rekey_subscription = rekey::subscription_id(&id);
self.watches.register(rekey_subscription.clone(), id);
self.synced.insert(id, key);
self.tasks.push(cx.spawn(async move |this, cx| {
@@ -433,6 +477,12 @@ impl CommunityRegistry {
})?;
}
if let Err(error) = subscribe(&client, &rekey_subscription, &relays, watch).await {
this.update(cx, |_this, cx| {
cx.emit(CommunityEvent::Error(error.to_string()));
})?;
}
Ok(())
}));
}
@@ -448,6 +498,7 @@ impl CommunityRegistry {
let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
let pages = self.pages.clone();
let watches = self.watches.clone();
let executor = cx.background_executor().clone();
self.notification_listener = Some(cx.background_spawn(async move {
@@ -457,7 +508,7 @@ impl CommunityRegistry {
'outer: loop {
match notifications.next().await {
Some(notification) => {
if route(notification, &pages, &mut batch) == Flow::Stop {
if route(notification, &pages, &watches, &mut batch) == Flow::Stop {
flush(&tx, &mut batch).await?;
break 'outer;
}
@@ -481,7 +532,7 @@ impl CommunityRegistry {
match select(next, timer).await {
Either::Left((Some(notification), _)) => {
if route(notification, &pages, &mut batch) == Flow::Stop {
if route(notification, &pages, &watches, &mut batch) == Flow::Stop {
flush(&tx, &mut batch).await?;
break 'outer;
}
@@ -501,6 +552,7 @@ impl CommunityRegistry {
while let Ok(signal) = rx.recv_async().await {
match signal {
Signal::Event(id) => this.update(cx, |this, cx| 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))?,
}
}
@@ -605,25 +657,53 @@ mod tests {
#[test]
fn a_burst_collapses_to_one_signal_per_community() {
let pages = PageRegistry::default();
let watches = WatchRegistry::default();
let community = CommunityId::from_bytes([0x42; 32]);
let mut batch = Batch::default();
let plane = event(sync::subscription_id(&community));
for _ in 0..50 {
assert_eq!(route(plane.clone(), &pages, &mut batch), Flow::Continue);
assert_eq!(
route(plane.clone(), &pages, &watches, &mut batch),
Flow::Continue
);
}
route(event(sync::list_subscription_id()), &pages, &mut batch);
route(
event(sync::list_subscription_id()),
&pages,
&watches,
&mut batch,
);
// A page's event is folded from the database later, not routed here.
route(
event(SubscriptionId::new("concord-history-7")),
&pages,
&watches,
&mut batch,
);
assert!(batch.list);
assert_eq!(batch.communities, BTreeSet::from([community]));
assert!(batch.rekeys.is_empty());
}
/// A rekey watch's wraps put themselves in the database; the pump's only job
/// is to wake the adoption pass, and it resolves the community by id.
#[test]
fn a_rekey_watch_event_wakes_its_community() {
let pages = PageRegistry::default();
let watches = WatchRegistry::default();
let community = CommunityId::from_bytes([0x42; 32]);
let id = rekey::subscription_id(&community);
watches.register(id.clone(), community);
let mut batch = Batch::default();
route(event(id), &pages, &watches, &mut batch);
assert_eq!(batch.rekeys, BTreeSet::from([community]));
assert!(batch.communities.is_empty());
}
#[test]
@@ -637,7 +717,12 @@ mod tests {
pages.register(other, other_tx);
let mut batch = Batch::default();
let flow = route(message(RelayMessage::eose(mine)), &pages, &mut batch);
let flow = route(
message(RelayMessage::eose(mine)),
&pages,
&WatchRegistry::default(),
&mut batch,
);
assert_eq!(flow, Flow::Continue);
assert!(matches!(
@@ -658,6 +743,7 @@ mod tests {
route(
message(RelayMessage::closed(id, "blocked: not allowed")),
&pages,
&WatchRegistry::default(),
&mut batch,
);
@@ -683,6 +769,7 @@ mod tests {
"auth-required: please authenticate",
)),
&pages,
&WatchRegistry::default(),
&mut batch,
);
@@ -695,7 +782,12 @@ mod tests {
let mut batch = Batch::default();
assert_eq!(
route(ClientNotification::Shutdown, &pages, &mut batch),
route(
ClientNotification::Shutdown,
&pages,
&WatchRegistry::default(),
&mut batch
),
Flow::Stop
);
}
+729
View File
@@ -0,0 +1,729 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, Mutex, MutexGuard};
use anyhow::Result;
use concord::cord04::roles::{CommunityRoles, Permissions};
use concord::cord06::{self, Continuity, RekeyScope, Rotation, RotationKey};
use concord::derive::{GroupKey, channel_rekey_group_key};
use concord::state::{CommunityState, HeldKey};
use concord::{ChannelId, CommunityId, Epoch, cord01};
use nostr_sdk::prelude::*;
use state::UniversalSigner;
/// Epochs ahead of a held epoch a rotation is looked for.
pub const REKEY_LOOKAHEAD: u64 = 8;
/// How much of what a relay stores a rekey watch replays.
const REKEY_REPLAY: usize = 200;
/// One address the rekey watch asks a relay for, and what a wrap at it means.
#[derive(Debug, Clone)]
pub struct Watch {
pub address: PublicKey,
pub group: GroupKey,
pub scope: RekeyScope,
pub epoch: Epoch,
}
/// Every address a community's rotations can arrive at.
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 channel in &state.channels {
if !channel.private {
continue;
}
for root in &roots {
for ahead in 1..=REKEY_LOOKAHEAD {
let epoch = Epoch(channel.epoch.0 + ahead);
let group = channel_rekey_group_key(&root.key, &channel.id, epoch)?;
watches.push(Watch {
address: group.pk(),
group,
scope: RekeyScope::Channel(channel.id),
epoch,
});
}
}
}
Ok(watches)
}
pub fn watch_filter(watches: &[Watch]) -> Filter {
Filter::new()
.kind(Kind::GiftWrap)
.authors(watches.iter().map(|watch| watch.address))
.limit(REKEY_REPLAY)
}
/// The subscription carrying a community's rekey watch.
pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
SubscriptionId::new(format!("rekey-{}", &id.to_hex()[..32]))
}
/// Which community a rekey watch subscription belongs to.
#[derive(Default)]
pub struct WatchRegistry {
watches: Arc<Mutex<HashMap<SubscriptionId, CommunityId>>>,
}
impl Clone for WatchRegistry {
fn clone(&self) -> Self {
Self {
watches: Arc::clone(&self.watches),
}
}
}
impl WatchRegistry {
pub fn register(&self, id: SubscriptionId, community: CommunityId) {
self.lock().insert(id, community);
}
pub fn community_of(&self, id: &SubscriptionId) -> Option<CommunityId> {
self.lock().get(id).copied()
}
/// Forget every watch, because the account that installed them is gone.
pub fn clear(&self) {
self.lock().clear();
}
fn lock(&self) -> MutexGuard<'_, HashMap<SubscriptionId, CommunityId>> {
match self.watches.lock() {
Ok(watches) => watches,
Err(poisoned) => poisoned.into_inner(),
}
}
}
/// What one adoption pass learned, ready to be folded into the held state.
#[derive(Debug, Default)]
pub struct Adoptions {
pub base: Option<BaseAdoption>,
pub channels: Vec<ChannelAdoption>,
/// Channels a complete rotation removed us from, with the epoch that did it.
pub cuts: Vec<(ChannelId, Epoch)>,
/// The base epoch a complete rotation excluded us at.
pub removed_at: Option<Epoch>,
pub stranded: bool,
}
impl Adoptions {
pub fn is_empty(&self) -> bool {
self.base.is_none()
&& self.channels.is_empty()
&& self.cuts.is_empty()
&& self.removed_at.is_none()
&& !self.stranded
}
}
#[derive(Debug, Clone)]
pub struct BaseAdoption {
pub epoch: Epoch,
pub key: [u8; 32],
pub control_pk: Option<PublicKey>,
pub stepped: Vec<HeldKey>,
}
#[derive(Debug, Clone)]
pub struct ChannelAdoption {
pub channel: ChannelId,
pub epoch: Epoch,
pub key: [u8; 32],
/// The keys this rotation stepped off, newest first.
pub stepped: Vec<HeldKey>,
}
/// Read every rekey wrap the local database holds and adopt what is admissible.
pub async fn adopt(
client: &Client,
state: &CommunityState,
roles: &CommunityRoles,
signer: &UniversalSigner,
me: PublicKey,
) -> Result<Adoptions> {
let watches = watches(state)?;
let mut authors: BTreeSet<PublicKey> = BTreeSet::new();
for watch in &watches {
authors.insert(watch.address);
}
if authors.is_empty() {
return Ok(Adoptions::default());
}
let wraps = client
.database()
.query(Filter::new().kind(Kind::GiftWrap).authors(authors))
.await?;
let mut chunks = Vec::new();
let mut published: BTreeMap<RotationKey, u64> = BTreeMap::new();
for wrap in &wraps {
let Some(watch) = watches.iter().find(|watch| watch.address == wrap.pubkey) else {
continue;
};
let Ok(opened) = cord01::open_wrap(wrap, &watch.group) else {
continue;
};
let Ok(chunk) = cord06::parse_rekey_chunk(&opened) else {
continue;
};
if chunk.scope != watch.scope || chunk.new_epoch != watch.epoch {
continue;
}
let at_ms = wrap.created_at.as_secs().saturating_mul(1000);
published
.entry(chunk.correlation())
.and_modify(|held| *held = (*held).min(at_ms))
.or_insert(at_ms);
chunks.push(chunk);
}
if chunks.is_empty() {
return Ok(Adoptions::default());
}
let rotations = cord06::collect_rotations(&chunks);
let mut adoptions = Adoptions::default();
let base = walk(
RekeyScope::Base,
&[Permissions::BAN],
state.root_epoch,
state.community_root,
state,
roles,
signer,
me,
&rotations,
&published,
)
.await?;
if let Some(step) = base.adopted {
adoptions.base = Some(BaseAdoption {
epoch: step.epoch,
key: step.key,
control_pk: step.control_pk,
stepped: step.stepped,
});
}
adoptions.removed_at = base.removed_at;
adoptions.stranded = base.stranded;
for channel in &state.channels {
if !channel.private {
continue;
}
let Some((held_epoch, held_key)) = channel.current() else {
continue;
};
let step = walk(
RekeyScope::Channel(channel.id),
&[Permissions::MANAGE_CHANNELS, Permissions::BAN],
held_epoch,
held_key,
state,
roles,
signer,
me,
&rotations,
&published,
)
.await?;
if let Some(adopted) = step.adopted {
adoptions.channels.push(ChannelAdoption {
channel: channel.id,
epoch: adopted.epoch,
key: adopted.key,
stepped: adopted.stepped,
});
}
if let Some(epoch) = step.removed_at {
adoptions.cuts.push((channel.id, epoch));
}
}
Ok(adoptions)
}
/// The key grouping a rotation's chunks, recomputed from a collected rotation.
fn rotation_key(rotation: &Rotation) -> RotationKey {
(
rotation.rotator.to_bytes(),
rotation.scope.id32(),
rotation.new_epoch.0,
rotation.prev_commit,
)
}
/// What one scope's walk found.
#[derive(Debug, Default)]
struct Step {
adopted: Option<Adopted>,
removed_at: Option<Epoch>,
stranded: bool,
}
#[derive(Debug, Clone)]
struct Adopted {
epoch: Epoch,
key: [u8; 32],
control_pk: Option<PublicKey>,
stepped: Vec<HeldKey>,
}
/// Walk a scope's rotations forward, one epoch at a time, off the key held.
#[allow(clippy::too_many_arguments)]
async fn walk(
scope: RekeyScope,
permissions: &[u64],
mut held_epoch: Epoch,
mut held_key: [u8; 32],
state: &CommunityState,
roles: &CommunityRoles,
signer: &UniversalSigner,
me: PublicKey,
rotations: &[Rotation],
published: &BTreeMap<RotationKey, u64>,
) -> Result<Step> {
let mut step = Step::default();
let mut stepped: Vec<HeldKey> = Vec::new();
let ceiling = held_epoch.0 + REKEY_LOOKAHEAD;
loop {
let target = Epoch(held_epoch.0 + 1);
if target.0 > ceiling {
break;
}
let candidates: Vec<&Rotation> = rotations
.iter()
.filter(|rotation| {
rotation.scope == scope
&& rotation.new_epoch == target
&& rotation.is_complete()
&& !state.banned.contains(&rotation.rotator)
&& permissions
.iter()
.any(|bits| roles.is_authorized(&rotation.rotator, &state.owner, *bits))
})
.collect();
if candidates.is_empty() {
break;
}
let mut delivery: Option<([u8; 32], Option<PublicKey>, u64)> = None;
let mut addressed = false;
for rotation in candidates
.iter()
.filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends)
{
let at_ms = published
.get(&rotation_key(rotation))
.copied()
.unwrap_or_default();
let blobs = cord06::find_my_blobs(
&rotation.blobs,
&rotation.rotator,
&me,
scope,
rotation.new_epoch,
)
.collect::<Vec<_>>();
if blobs.is_empty() {
continue;
}
addressed = true;
for blob in blobs {
let Ok(delivered) = cord06::open_blob(
signer,
&rotation.rotator,
scope,
rotation.new_epoch,
blob,
&state.id,
)
.await
else {
continue;
};
let control_pk = delivered
.control_pk
.and_then(|bytes| PublicKey::from_slice(&bytes).ok());
let raced = delivery.as_ref().map(|held| held.0);
// Racing rotations converge on the lowest new key.
if raced.is_none_or(|raced| delivered.new_key < raced) {
delivery = Some((delivered.new_key, control_pk, at_ms));
} else if let Some(held) = delivery.as_mut() {
held.2 = held.2.min(at_ms);
}
}
}
if let Some((key, control_pk, at_ms)) = delivery {
stepped.insert(
0,
HeldKey {
epoch: held_epoch,
key: held_key,
retired_at: Some(at_ms / 1000),
},
);
held_epoch = target;
held_key = key;
step.adopted = Some(Adopted {
epoch: target,
key,
control_pk,
stepped: stepped.clone(),
});
continue;
}
if addressed {
break;
}
let judged: Vec<&&Rotation> = candidates
.iter()
.filter(|rotation| rotation.continuity(held_epoch, &held_key) != Continuity::Fork)
.collect();
let published_at = |rotation: &&Rotation| {
published
.get(&rotation_key(rotation))
.copied()
.unwrap_or_default()
};
if judged.iter().any(|rotation| {
published_at(rotation) >= state.added_at_ms
&& permissions.iter().any(|bits| {
roles.can_act_on_member(&rotation.rotator, &state.owner, &me, *bits)
})
}) {
step.removed_at = Some(target);
} else if scope == RekeyScope::Base
&& judged
.iter()
.any(|rotation| published_at(rotation) < state.added_at_ms)
{
step.stranded = true;
}
break;
}
Ok(step)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use concord::cord04::roles::CommunityRoles;
use concord::cord06::{RekeyBlob, build_blob, build_rekey_chunks};
use concord::derive::{
base_rekey_group_key, channel_rekey_group_key, control_signer_group_key,
epoch_key_commitment,
};
use concord::state::ChannelKeyRef;
use nostr_memory::MemoryDatabase;
use super::*;
const AT_MS: u64 = 1_700_000_000_000;
const ROOT: [u8; 32] = [0x55; 32];
const NEW_ROOT: [u8; 32] = [0x66; 32];
const CHANNEL_KEY: [u8; 32] = [0x07; 32];
const NEW_CHANNEL_KEY: [u8; 32] = [0x08; 32];
fn client() -> Client {
ClientBuilder::default()
.database(MemoryDatabase::unbounded())
.build()
}
fn state(owner: PublicKey, id: CommunityId, channel: ChannelId) -> CommunityState {
CommunityState {
id,
name: Some("Room".to_owned()),
owner,
owner_salt: [0x01; 32],
community_root: ROOT,
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::from([(0, owner)]),
channels: vec![ChannelKeyRef {
id: channel,
name: "staff".to_owned(),
private: true,
epoch: Epoch(0),
key: Some(CHANNEL_KEY),
priors: Vec::new(),
}],
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: AT_MS,
}
}
async fn store(client: &Client, wraps: &[Event]) {
for wrap in wraps {
client.database().save_event(wrap).await.expect("saves");
}
}
fn base_chunks(
owner: &Keys,
id: &CommunityId,
prior_commit: &[u8; 32],
blobs: &[RekeyBlob],
) -> Vec<Event> {
let group = base_rekey_group_key(&ROOT, id, Epoch(1)).expect("derives");
smol::block_on(build_rekey_chunks(
owner,
&group,
RekeyScope::Base,
Epoch(1),
Epoch(0),
prior_commit,
blobs,
None,
false,
AT_MS / 1000,
))
.expect("builds")
}
fn channel_chunks(
owner: &Keys,
channel: &ChannelId,
prior_commit: &[u8; 32],
blobs: &[RekeyBlob],
) -> Vec<Event> {
let scope = RekeyScope::Channel(*channel);
let group = channel_rekey_group_key(&ROOT, channel, Epoch(1)).expect("derives");
smol::block_on(build_rekey_chunks(
owner,
&group,
scope,
Epoch(1),
Epoch(0),
prior_commit,
blobs,
None,
false,
AT_MS / 1000,
))
.expect("builds")
}
fn blob_for(
rotator: &Keys,
recipient: &Keys,
scope: RekeyScope,
key: [u8; 32],
control_pk: Option<&[u8; 32]>,
) -> RekeyBlob {
smol::block_on(build_blob(
rotator,
&recipient.public_key(),
scope,
Epoch(1),
&key,
control_pk,
None,
))
.expect("builds")
}
#[test]
fn a_complete_base_rotation_is_adopted_and_retires_the_prior_root() {
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]);
let state = state(owner.public_key(), id, channel);
let control_root = [0xAB; 32];
let control_pk = control_signer_group_key(&control_root, &id, Epoch(1))
.expect("derives")
.pk()
.to_bytes();
let blob = blob_for(&owner, &me, RekeyScope::Base, NEW_ROOT, Some(&control_pk));
let wraps = base_chunks(&owner, &id, &epoch_key_commitment(Epoch(0), &ROOT), &[blob]);
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let roles = CommunityRoles::default();
let adoptions = adopt(&client, &state, &roles, &signer, me.public_key())
.await
.expect("reads");
let base = adoptions.base.expect("adopted");
assert_eq!(base.epoch, Epoch(1));
assert_eq!(base.key, NEW_ROOT);
assert_eq!(base.control_pk, PublicKey::from_slice(&control_pk).ok());
assert_eq!(base.stepped.len(), 1);
assert_eq!(base.stepped[0].epoch, Epoch(0));
assert_eq!(base.stepped[0].key, ROOT);
assert_eq!(base.stepped[0].retired_at, Some(AT_MS / 1000));
assert!(adoptions.removed_at.is_none());
assert!(!adoptions.stranded);
});
}
/// 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() {
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]);
let state = state(owner.public_key(), id, channel);
let blob = blob_for(&owner, &me, RekeyScope::Base, NEW_ROOT, None);
let wraps = base_chunks(
&owner,
&id,
&epoch_key_commitment(Epoch(0), &[0x99; 32]),
&[blob],
);
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let roles = CommunityRoles::default();
let adoptions = adopt(&client, &state, &roles, &signer, me.public_key())
.await
.expect("reads");
assert!(adoptions.is_empty());
});
}
/// Every chunk held, none carrying my blob, from a rotator who outranks me
/// and published after I joined: I was excluded, not stranded.
#[test]
fn a_blobless_rotation_from_an_outranking_rotator_removes_the_member() {
smol::block_on(async {
let client = client();
let owner = Keys::generate();
let me = Keys::generate();
let other = Keys::generate();
let id = CommunityId::from_bytes([0x42; 32]);
let channel = ChannelId::from_bytes([0x9c; 32]);
let state = state(owner.public_key(), id, channel);
let blob = blob_for(&owner, &other, RekeyScope::Base, NEW_ROOT, None);
let wraps = base_chunks(&owner, &id, &epoch_key_commitment(Epoch(0), &ROOT), &[blob]);
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let roles = CommunityRoles::default();
let adoptions = adopt(&client, &state, &roles, &signer, me.public_key())
.await
.expect("reads");
assert!(adoptions.base.is_none());
assert_eq!(adoptions.removed_at, Some(Epoch(1)));
assert!(!adoptions.stranded);
});
}
#[test]
fn a_channel_rotation_replaces_the_key_and_keeps_the_prior() {
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]);
let state = state(owner.public_key(), id, channel);
let blob = blob_for(
&owner,
&me,
RekeyScope::Channel(channel),
NEW_CHANNEL_KEY,
None,
);
let wraps = channel_chunks(
&owner,
&channel,
&epoch_key_commitment(Epoch(0), &CHANNEL_KEY),
&[blob],
);
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let roles = CommunityRoles::default();
let adoptions = adopt(&client, &state, &roles, &signer, me.public_key())
.await
.expect("reads");
assert_eq!(adoptions.channels.len(), 1);
let adopted = &adoptions.channels[0];
assert_eq!(adopted.channel, channel);
assert_eq!(adopted.epoch, Epoch(1));
assert_eq!(adopted.key, NEW_CHANNEL_KEY);
assert_eq!(adopted.stepped.len(), 1);
assert_eq!(adopted.stepped[0].epoch, Epoch(0));
assert_eq!(adopted.stepped[0].key, CHANNEL_KEY);
assert_eq!(adopted.stepped[0].retired_at, Some(AT_MS / 1000));
assert!(adoptions.cuts.is_empty());
});
}
}
+137 -21
View File
@@ -10,7 +10,7 @@ use concord::cord04::roles::{Permissions, citation_ok};
use concord::derive::{
channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key,
};
use concord::state::{CommunityState, list_entry};
use concord::state::{CommunityState, HeldKey, HeldRoot, list_entry};
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
use gpui::AsyncApp;
use nostr_sdk::prelude::*;
@@ -35,18 +35,42 @@ pub struct Plane {
/// The wrap's author: the control signer for Control, the group's own key otherwise.
pub address: PublicKey,
pub group: GroupKey,
/// Epoch seconds the key behind this plane was retired.
pub retired_at: Option<u64>,
}
impl Plane {
/// Whether a wrap sealed under this plane is still inside its key's life.
pub fn accepts(&self, wrap: &Event) -> bool {
self.retired_at
.is_none_or(|retired| wrap.created_at.as_secs() <= retired)
}
}
pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
let mut planes = Vec::new();
let roots = state.roots();
for (epoch, address) in &state.control_pks {
let epoch = Epoch(*epoch);
let group = control_group_key(&state.community_root, &state.id, epoch)?;
// An epoch's Control Plane reads under the root that was current then,
// so a rotation that kept a floor for the prior root republishes here.
let root = roots
.iter()
.find(|root| root.epoch == epoch)
.copied()
.unwrap_or(HeldRoot {
epoch,
key: state.community_root,
control_pk: None,
retired_at: None,
});
let group = control_group_key(&root.key, &state.id, epoch)?;
planes.push(Plane {
kind: PlaneKind::Control(epoch),
address: *address,
group,
retired_at: root.retired_at,
});
}
@@ -56,31 +80,38 @@ pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
kind: PlaneKind::Control(state.root_epoch),
address: group.pk(),
group,
retired_at: None,
});
}
let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?;
planes.push(Plane {
kind: PlaneKind::Guestbook,
address: group.pk(),
group,
});
for channel in &state.channels {
let secret = match (channel.private, channel.key) {
(true, None) => continue,
(true, Some(key)) => key,
(false, _) => state.community_root,
};
let group = channel_group_key(&secret, &channel.id, channel.epoch)?;
for root in &roots {
let group = guestbook_group_key(&root.key, &state.id, root.epoch)?;
planes.push(Plane {
kind: PlaneKind::Channel(channel.id, channel.epoch),
kind: PlaneKind::Guestbook,
address: group.pk(),
group,
retired_at: root.retired_at,
});
}
for channel in &state.channels {
for held in state.held_keys(&channel.id) {
let group = channel_group_key(&held.key, &channel.id, held.epoch)?;
planes.push(Plane {
kind: PlaneKind::Channel(channel.id, held.epoch),
address: group.pk(),
group,
retired_at: held.retired_at,
});
}
}
// A rotation can re-derive an address the current root already produced.
//
// A duplicate author would only repeat a filter, so keep the set unique.
let mut seen = BTreeSet::new();
planes.retain(|plane| seen.insert(plane.address));
Ok(planes)
}
@@ -389,17 +420,44 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
held.relays = fresh.relays;
for channel in fresh.channels {
let cut = held.channel_cuts.get(&channel.id).copied();
match held.channels.iter_mut().find(|held| held.id == channel.id) {
Some(held) => {
held.name = channel.name;
held.epoch = channel.epoch;
if cut.is_some_and(|cut| channel.epoch.0 <= cut) {
continue;
}
if channel.private {
held.private = true;
held.key = channel.key;
if let Some(key) = channel.key
&& (held.key != Some(key) || held.epoch != channel.epoch)
{
// The key being superseded still reads everything
// written under it, so it is retained, never overwritten.
if let Some((epoch, previous)) = held.current()
&& !held.priors.iter().any(|prior| prior.epoch == epoch)
{
held.priors.push(HeldKey {
epoch,
key: previous,
retired_at: None,
});
}
held.key = Some(key);
held.epoch = channel.epoch;
}
}
}
None => {
if cut.is_none() {
held.channels.push(channel);
}
}
None => held.channels.push(channel),
}
}
@@ -486,6 +544,11 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
continue;
};
// A retired key reads only what was sealed before its rotation published.
if !plane.accepts(wrap) {
continue;
}
match plane.kind {
PlaneKind::Control(_) => {
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
@@ -609,6 +672,7 @@ mod tests {
private: false,
epoch: Epoch(0),
key: None,
priors: Vec::new(),
},
concord::state::ChannelKeyRef {
id: staff,
@@ -616,6 +680,7 @@ mod tests {
private: true,
epoch: Epoch(0),
key: Some([0x04; 32]),
priors: Vec::new(),
},
concord::state::ChannelKeyRef {
id: ChannelId::from_bytes([0x9e; 32]),
@@ -623,12 +688,17 @@ mod tests {
private: true,
epoch: Epoch(0),
key: None,
priors: Vec::new(),
},
],
relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")],
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: 0,
};
@@ -719,11 +789,16 @@ mod tests {
private: false,
epoch: Epoch(0),
key: None,
priors: Vec::new(),
}],
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: 1_700_000_000_000,
}
@@ -1021,4 +1096,45 @@ mod tests {
assert!(subscription_id(&id).as_str().len() <= 64);
}
fn event_at(at_ms: u64) -> Event {
EventBuilder::new(Kind::TextNote, "wrap")
.custom_created_at(Timestamp::from_secs(at_ms / 1000))
.finalize(&Keys::generate())
.expect("signs")
}
/// A key a rotation stepped off still reads its own history, but nothing
/// sealed after the rotation published it.
#[test]
fn a_superseded_key_reads_only_up_to_the_rotation_that_retired_it() {
let channel = ChannelId::from_bytes([0x9c; 32]);
let mut state = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
state.channels = vec![concord::state::ChannelKeyRef {
id: channel,
name: "staff".to_owned(),
private: true,
epoch: Epoch(1),
key: Some([0x08; 32]),
priors: vec![HeldKey {
epoch: Epoch(0),
key: [0x07; 32],
retired_at: Some(1_000),
}],
}];
let planes = planes(&state).expect("planes");
let retired = planes
.iter()
.find(|plane| plane.kind == PlaneKind::Channel(channel, Epoch(0)))
.expect("the retired epoch keeps a plane");
assert_eq!(retired.retired_at, Some(1_000));
assert!(retired.accepts(&event_at(1_000_000)));
assert!(!retired.accepts(&event_at(1_001_000)));
}
}
+108
View File
@@ -17,6 +17,27 @@ use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
/// The `concord/` namespace for locally-keyed documents.
pub const STATE_PREFIX: &str = "concord/";
/// A key epoch the client still holds, retained so history stays readable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeldKey {
pub epoch: Epoch,
pub key: [u8; 32],
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<u64>,
}
/// A community root epoch the client still holds, retained for the same reason.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeldRoot {
pub epoch: Epoch,
pub key: [u8; 32],
/// The epoch's Control Plane signer, when the rotation delivered one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelKeyRef {
pub id: ChannelId,
@@ -26,6 +47,16 @@ pub struct ChannelKeyRef {
/// The channel's read secret when the member was granted it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<[u8; 32]>,
/// Keys this one superseded, retained so a rotation never blanks history.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub priors: Vec<HeldKey>,
}
impl ChannelKeyRef {
/// The write coordinate: only the current epoch is ever published under.
pub fn current(&self) -> Option<(Epoch, [u8; 32])> {
self.key.map(|key| (self.epoch, key))
}
}
/// How far a channel's history sync has reached, in epoch milliseconds.
@@ -92,6 +123,19 @@ pub struct CommunityState {
/// Where each channel's history sync has reached.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
/// Root epochs superseded by a rotation we adopted, newest first.
#[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, u64>,
/// The base epoch we were excluded at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_at: Option<Epoch>,
/// A complete rotation ahead of our epoch predates our join and carries no
/// blob for us: a stale invite landed us on a superseded epoch.
#[serde(default)]
pub stranded: bool,
#[serde(default)]
pub dissolved: bool,
pub added_at_ms: u64,
@@ -135,6 +179,7 @@ impl CommunityState {
private: metadata.private,
epoch: ROOT_EPOCH,
key: None,
priors: Vec::new(),
});
}
_ => {}
@@ -165,6 +210,10 @@ impl CommunityState {
heads,
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms,
})
@@ -177,6 +226,7 @@ impl CommunityState {
};
let mut channels = Vec::with_capacity(material.channels.len());
for grant in &material.channels {
let key = match &grant.key {
Some(key) => Some(decode_hex_32(key)?),
@@ -189,6 +239,7 @@ impl CommunityState {
private: key.is_some(),
epoch: grant.epoch,
key,
priors: Vec::new(),
});
}
@@ -213,6 +264,10 @@ impl CommunityState {
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms,
})
@@ -222,6 +277,58 @@ impl CommunityState {
state_identifier(&self.id)
}
/// Every root epoch we hold, the current one first.
pub fn roots(&self) -> Vec<HeldRoot> {
let mut roots = Vec::with_capacity(self.held_roots.len() + 1);
roots.push(HeldRoot {
epoch: self.root_epoch,
key: self.community_root,
control_pk: self.control_pks.get(&self.root_epoch.0).copied(),
retired_at: None,
});
roots.extend(self.held_roots.iter().copied());
roots
}
/// Every secret held for a channel, newest epoch first.
pub fn held_keys(&self, channel: &ChannelId) -> Vec<HeldKey> {
let Some(held) = self.channels.iter().find(|held| held.id == *channel) else {
return Vec::new();
};
if held.private {
let mut keys: Vec<HeldKey> = held
.key
.map(|key| HeldKey {
epoch: held.epoch,
key,
retired_at: None,
})
.into_iter()
.collect();
keys.extend(held.priors.iter().copied());
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,
key: root.key,
retired_at: root.retired_at,
})
.collect()
}
/// Whether a channel rotation removed us at or after `epoch`.
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
self.channel_cuts
.get(channel)
.is_some_and(|cut| epoch.0 <= *cut)
}
pub fn floors(&self) -> Floors {
self.heads
.iter()
@@ -265,6 +372,7 @@ impl CommunityState {
private: false,
epoch: self.root_epoch,
key: None,
priors: Vec::new(),
}),
None => {}
}
+95 -18
View File
@@ -36,7 +36,8 @@ subscriptions too, which is what a page REQ is (`nostr-sdk/src/relay/inner.rs`
wait, and it is reported rather than rendered as an empty channel.
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):
swapped the transport and put the pump in charge of settling pages, phase 3 added
the rekey watch and held epochs):
```
CommunityPanel::load community_ui/src/lib.rs:192
@@ -60,10 +61,17 @@ live wire community/src/lib.rs:309 (sync_subscriptions)
private channels,
Window::opening,
limit LIVE_REPLAY)
-> rekey::watches community/src/rekey.rs (base next epoch +
a channel window per
held root)
-> pump community/src/lib.rs:433 (routes by subscription
id, batches within
PUMP_WINDOW, settles
pages by EOSE/CLOSED)
-> rekey::adopt community/src/rekey.rs (reads the rekey wraps
back from the database,
adopts one epoch at a
time)
```
## What is wrong today
@@ -76,7 +84,7 @@ live wire community/src/lib.rs:309 (sync_subscriptions)
| 4 | Cursors existed but nothing used them; no "has more" signal. | fixed in phase 1 | `community/src/community.rs:337` |
| 5 | **Private channels are never subscribed and never folded**: `planes()` skips `channel.private`, so a private channel gets no live REQ and no `cache_rumor` from the subscription. | fixed in phase 2b (`planes` derives a private channel's plane from the held key; it is subscribed, paged and folded like a public one) | `community/src/sync.rs` |
| 6 | The standing REQ asks for **kind 1059 only**, and the pump drops anything that is not 1059, so 21059 (ephemeral) wraps can never be routed even though the read path asks for both kinds. | fixed in phase 2b (`live_filter`/`plane_filter` ask for both kinds; the pump routes by subscription id and never inspects the kind) | `community/src/sync.rs`, `community/src/lib.rs` |
| 7 | A **rekey strands history**: `ChannelKeyRef` holds one epoch/key, `channel_secret` returns one plane, `sync::refresh` overwrites a held key in place, and the rekey pseudonyms are never watched. | open (phase 3) | `community/src/sync.rs:331-368`, `community/src/community.rs:199-216` |
| 7 | A **rekey strands history**: `ChannelKeyRef` holds one epoch/key, `channel_secret` returns one plane, `sync::refresh` overwrites a held key in place, and the rekey pseudonyms are never watched. | fixed in phase 3 (`priors`/`held_roots` + `retired_at`, the rekey watch, and strict one-epoch-at-a-time adoption) | `community/src/rekey.rs`, `concord/src/state.rs` |
| 8 | **"No messages yet" is three different states**: unreadable wraps are dropped silently, a failed round is logged, and the panel renders all of them as an empty room. | open (phase 4; counts already exist in `Progress`) | `community/src/community.rs:38-44`, `community_ui/src/lib.rs:594-602` |
| 9 | A new message **replaced the whole timeline and forced `scroll_to_end()`**. | fixed in phase 1 (`FollowMode::Tail`, in-place merge) | `community_ui/src/lib.rs:121`, `:364-411` |
| 10 | Backfill fetched through `client.fetch_events(..)` with `ReqTarget::auto`, i.e. every relay in the pool. | fixed in phase 1 (relay-scoped, no `fetch_events` anywhere) | `community/src/community.rs:262` |
@@ -177,7 +185,7 @@ Notes:
| Community List | `concord/list` (existing) | registry | signer lifetime |
| Live planes | `<community hex>` (existing `sync::subscription_id`) | community | until the signer or the plane set changes; **kept alive** |
| History page | `concord-history-<n>` (opaque, unique) | the round | one page; auto-closes on EOSE |
| Rekey watch (phase 3) | `concord-rekey/<community hex>/<n>` | community | kept alive while the community is tracked |
| Rekey watch | `rekey-<community hex, first 32>` (opaque) | community | kept alive while the community is tracked |
`route_of(&SubscriptionId) -> Option<Route>` parses the two **standing** ids into
`Route::{List, Community(CommunityId)}`, so the pump routes an event by
@@ -192,7 +200,10 @@ instead — `PageRegistry`, a `HashMap<SubscriptionId, flume::Sender<PageReport>
shared between the registry and the rounds. `PageReport { relay, outcome }`
carries facts; the walk decides what they mean. A report for an id nobody is
waiting on is dropped at `debug`, which is the ordinary case when a round is
cancelled. Phase 3 can add a `Rekey` route the same way, by id.
cancelled. Phase 3 added the `Rekey` route the same way, through a parallel
`rekey::WatchRegistry` that maps an opaque `rekey-<32 hex>` id back to its
community: the whole id is sent to the relay, so a page's rule applies — an id
cannot carry a community hex and stay within the cap.
#### The pump
@@ -412,8 +423,9 @@ pub fn timeline(&self, channel: &ChannelId, before_ms: Option<u64>, limit: usize
### 5. Live completeness
- **Private channels get a plane.** `sync::planes` derives it from the held key
(`channel_secret` already knows the rule); a private channel is subscribed and
- **Private channels get a plane.** `sync::planes` derives one per held epoch
(the current key plus every `priors` entry), and a public channel one per held
root, so a rotation never blanks a plane; a private channel is subscribed and
folded exactly like a public one (finding 5).
- **Both wrap kinds.** The filter asks for `KIND_WRAP` and `KIND_WRAP_EPHEMERAL`
in the live REQ and in every page REQ, and the pump routes both (finding 6).
@@ -427,7 +439,7 @@ pub fn timeline(&self, channel: &ChannelId, before_ms: Option<u64>, limit: usize
twice then has one addressable coordinate, so `save_event` replaces its
predecessor instead of adding a copy per app run.
### 6. Held epochs and rekey adoption (phase 3)
### 6. Held epochs and rekey adoption (phase 3) — **landed**
The client has to be able to read the epochs it is supposed to read, and learn
the next ones:
@@ -465,6 +477,49 @@ the next ones:
epoch that predates my join and carries no blob for me, which says the invite
link is out of date).
**What landed.** `HeldKey { epoch, key, retired_at }` and
`HeldRoot { epoch, key, control_pk, retired_at }` live in `concord::state`;
`ChannelKeyRef` gained `priors` and `CommunityState` gained `held_roots`,
`channel_cuts`, `removed_at` and `stranded`. `CommunityState::roots()` and
`held_keys(channel)` are the read surface: a private channel returns its current
key plus every prior, a public channel one entry per held root. `sync::planes`
derives a plane for each, `history::page` takes `&[HeldKey]` and refuses a wrap
sealed under a retired key after its cutoff, and `sync::fold` applies the same
cutoff. `sync::refresh` no longer overwrites a held key in place — it pushes the
superseded one onto `priors` — and a recorded `channel_cuts` entry keeps a stale
grant from merging a removed channel back.
The wire side is `community/src/rekey.rs`: `watches(state)` builds the base
next-epoch address plus a `1..=8` channel window under every held root,
`watch_filter` is the one standing REQ over the community's relays, and `adopt`
reads the delivered wraps back out of the database and walks each scope forward.
`CommunityRegistry` installs the watch beside the live REQ when a community's
plane set changes, routes its events to `Signal::Rekey` through the pump, and
`Community::merge_adoptions` folds the result in, persists it, clears the moved
cursors' `exhausted` verdicts, and runs a `CatchUp` for the active channel.
Three deviations from the sketch above, all of them smaller than planned:
- **The rekey watch is one subscription per community, not per scope.** The base
and every private channel ride the same `authors` filter; the wraps are
addressed by pseudonym, so one REQ covers them all and one `watch_filter`
rebuild covers every adoption. The id is opaque (`rekey-<32 hex>`) and resolves
through `rekey::WatchRegistry` for the same 64-character reason a page id does.
- **A removal is judged without continuity, a strand with it.** Adoption requires
`Extends`; the removal/strand decision considers every complete authorized
rotation at the target epoch **except** one whose `prevcommit` forks (which is
neither, and acting on it is how a member ends up on a fork). A member who
missed a link is still removable — the reference's own channel watcher treats
"past my epoch" as the removal test.
- **Adoption chains within one pass.** `walk` loops `held + 1` while each step is
adoptable, so a member who missed several rotations catches up in one database
read instead of one pass per poll. The lookahead window is what feeds it.
Still deferred, and named here so it is not mistaken for landed: the panel does
not yet render `Community::removed_at()` / `Community::stranded()` (phase 4), and
a base removal is not enforced at send time — `channel_secret` still hands the
composer the retired root.
### 7. Honest states (phase 4)
- `Progress` already carries `fetched`, `opened`, `exhausted`, `failed`, `errors`;
@@ -578,23 +633,36 @@ not account for:
Also landed as the cheap win §3 promised: the older pass is skipped entirely when
`saved.exhausted` is already set.
### Phase 3 — epochs and rekeys
### Phase 3 — epochs and rekeys — **landed**
§6: `HeldKey`/`HeldRoot` + `priors`, `retired_at` as a read cutoff, the rekey
watch over every held root, strict one-epoch-at-a-time adoption, re-subscribe +
`CatchUp` after a delivery, removed versus stranded states.
1. The document (§6): `HeldKey`/`HeldRoot`, `ChannelKeyRef.priors`,
`CommunityState.held_roots`/`channel_cuts`/`removed_at`/`stranded`, and the
`roots()`/`held_keys()` read surface.
2. The read cutoff: `history::page` takes `&[HeldKey]` and refuses a wrap sealed
under a retired key after the rotation's publish time; `sync::fold` applies the
same rule from the channel's `priors`.
3. `sync::planes` derives planes from every held channel epoch and every held
root; `sync::refresh` preserves priors and honors recorded channel cuts.
4. The rekey watch and adoption (§6), `community/src/rekey.rs`, wired through the
pump's `Signal::Rekey` and a `WatchRegistry`.
5. Gate, all green: `cargo test -p concord -p community` (50 + 29),
`cargo clippy -p concord -p community -p community_ui --all-targets`,
`cargo +nightly fmt -p concord -p community -p community_ui --check`,
`cargo check -p workspace --all-targets`.
### Phase 4 — honest states and polish
§7 (empty/unreadable/failed in the panel, using the counts that already exist),
round progress in the UI, the `MIN_ROUND_INTERVAL = 30s` / `STALE_AFTER = 5min`
scheduler, and optional NIP-77 catch-up (`client.sync(filter)` where a relay
scheduler, the `removed`/`stranded` rendering phase 3 persisted but left
unpainted, and optional NIP-77 catch-up (`client.sync(filter)` where a relay
supports negentropy; "negentropy unsupported" means "fall back to the paged
walk", never "exhausted").
Each phase leaves the client consistent on its own. Phase 2a was invisible; phase
2b is what makes "open a community" a subscription and a database read. Phase 3
(§6) is next: it is what keeps a rekey from stranding history.
2b is what makes "open a community" a subscription and a database read; phase 3
is what keeps a rekey from stranding history. Phase 4 (§7) is next: it is what
makes an empty or unreadable room tell the truth.
## Phase 1 status (landed)
@@ -617,10 +685,10 @@ What phase 1 delivered, and what phase 2 replaces:
... the next round retries"). The SDK re-issues the REQ under the same id after
AUTH — for auto-closing subscriptions too — so the honest statement is: the
relay stays in the walk and the page waits for its resubscribed answer.
- Held epoch handling and rekeys are still open in §6, and honest empty states in
§7. Private planes, the second wrap kind, the expired-row sweep, the stable
cache key and the fold cost were open before phase 1 and are now landed (§5,
§9).
- Held epoch handling and rekeys landed in §6 (phase 3); honest empty states
remain open in §7. Private planes, the second wrap kind, the expired-row sweep,
the stable cache key and the fold cost were open before phase 1 and are now
landed (§5, §9).
## Tests
@@ -650,6 +718,15 @@ outstanding are the ones that need a GPUI harness or two live accounts.
carries a `since`, a cold open's does not) still needs a dev relay.
- The page registry — **landed in 2b**: a page that has already unregistered
receives nothing.
- The rekey watch and adoption — **landed in phase 3** (`community/src/rekey.rs`
tests): a complete base rotation whose `prevcommit` extends the held root is
adopted with the prior root retired at the rotation's publish time; a rotation
off a key we do not hold is never adopted; a complete blob-less rotation from a
rotator who outranks us, published after we joined, reads as a removal; a
channel rotation replaces the key and keeps the prior. The pump's half
(`community/src/lib.rs`) is that a rekey watch's event wakes its community.
- The page registry — **landed in 2b**: a page that has already unregistered
receives nothing.
- The read path: the side-event budget folds an edit/delete/reaction older than
the row window onto its message.
- The fold: a new live wrap costs one decrypt, and a fold over a community with
+42 -10
View File
@@ -224,6 +224,11 @@ Relay history pages through the local cache:
```rust
use community::history::{self, PageRegistry, Window};
// Every key the client still holds for the channel, newest epoch first: the
// state's current key (`ChannelKeyRef::key` at `epoch`) plus every `priors`
// entry a rotation stepped off. A public channel derives one per held root.
let held: Vec<concord::state::HeldKey> = state.held_keys(&channel);
// The same registry `CommunityRegistry` shares with its notification pump: it is
// what carries each page's EOSE and CLOSED back to the round waiting on it.
let page = history::page(
@@ -240,6 +245,10 @@ let page = history::page(
let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?;
```
A key's `retired_at` (epoch seconds, set when a rotation supersedes it) is a read
cutoff: a wrap at that epoch with a later `created_at` is refused, so a retired
epoch is history and never a live plane an ejected holder can keep writing into.
A relay is only ever *asked*: `history::page` installs **one REQ per page** over
`ReqTarget::manual` for the community's own relays (which is what makes the client
verify a wrap, deduplicate it and persist it), waits for every relay to settle,
@@ -446,6 +455,12 @@ member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the k
only if the plaintext binds to the scope and epoch they expect and its `prevcommit`
matches the key they already hold. Two concurrent rotations settle on `fork_winner`.
`community::rekey::adopt` is that receiver, run against the local database: it
walks a scope forward one epoch at a time off the key actually held (never
waiving a gap, never adopting a fork), keeps each stepped-off key as a prior
with the rotation's publish time as its read cutoff, and reports a removal or a
strand when a complete rotation carries no blob for the member.
The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is
text-only, so `build_blob` carries it base64-encoded inside the envelope.
`open_blob` mirrors that, so the record layout and the `locator` are unchanged.
@@ -635,10 +650,24 @@ client.subscribe(filter).with_id(sub_id).await?;
rekey fold changes it.
- Route inbound events by `subscription_id` from `RelayMessage::Event`, never by
kind.
- Watch one epoch ahead: while holding `root_N`, subscribe to
`base_rekey_group_key(&root_N, &community_id, Epoch(N + 1))` and to
`channel_rekey_group_key(&root_N, &channel, Epoch(N + 1))` for each private
channel. A second epoch ahead is not derivable until the new root arrives.
- Watch one epoch ahead for the base, and a **window** of channel epochs under
every held root for private channels: `base_rekey_group_key(&root_N, &id,
Epoch(N + 1))`, plus `channel_rekey_group_key(&root, &channel, Epoch(channel_epoch
+ ahead))` for `ahead in 1..=8` and each root in `state.roots()`. A Refounding
seals its channel rekeys under the root current when it was minted, so a member
who adopted the base rotation first must still ask under the prior root; the
window is what lets a member who missed a rotation catch up, or learn they were
cut. This is `community::rekey::watches`, installed as a second standing REQ
whose id resolves back to its community through `rekey::WatchRegistry` (the id
cannot carry a 64-hex community id within the NIP-01 length cap).
- `community::rekey::adopt` then reads those wraps **from the database** and walks
each scope forward one epoch at a time: a complete, authorized rotation whose
`prevcommit` extends the key actually held hands over the next key (raced
rotations settle on the lowest); a gap is fetched, never waived; a fork is never
adopted. An addressed-but-unverifiable rotation is neither adopted nor read as a
removal. Only a complete rotation at/after the join, from a rotator who outranks
the member, with no blob for them, is a removal; one that predates the join is a
strand — a stale invite landed the member on a superseded epoch.
### Tests
@@ -683,9 +712,12 @@ client.subscribe(filter).with_id(sub_id).await?;
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
that handler must route by subscription id before any concord subscription goes
live, or every stream wrap lands in the DM trash and raises a toast.
- **Rotation-delivered plane keys cannot be persisted yet.** `CommunityState` has
nowhere to keep a key a rotation delivered, so a client can verify a rotation
and still lose it on restart — history under a prior root or a prior channel
epoch is unreadable until that schema change lands. (A granted private-channel
key does now have a home: `ChannelKeyRef.key`, filled by
`CommunityState::from_join_material`.)
- **Rotation-delivered plane keys are persisted, and history spans them.**
`CommunityState.held_roots` keeps every root a rotation stepped off (with the
retired root's Control signer and the publish time that retires it), and each
`ChannelKeyRef.priors` keeps every channel key a rotation stepped off. The read
side derives planes from all of them, and a retired key's `retired_at` is a hard
read cutoff at both `history::page` and `sync::fold`. The residual gaps: the
panel does not yet render the `removed`/`stranded` state `Community::removed_at`
and `Community::stranded` carry (phase 4), and a base removal is not yet enforced
at send time — the composer still has the old root to write under.