update
This commit is contained in:
@@ -262,6 +262,10 @@ mod tests {
|
|||||||
heads: Vec::new(),
|
heads: Vec::new(),
|
||||||
banned: BTreeSet::new(),
|
banned: BTreeSet::new(),
|
||||||
cursors: BTreeMap::new(),
|
cursors: BTreeMap::new(),
|
||||||
|
held_roots: Vec::new(),
|
||||||
|
channel_cuts: BTreeMap::new(),
|
||||||
|
removed_at: None,
|
||||||
|
stranded: false,
|
||||||
dissolved: false,
|
dissolved: false,
|
||||||
added_at_ms: 7,
|
added_at_ms: 7,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use concord::cord02::{ControlFold, ImageRef};
|
|||||||
use concord::cord03::{self, ChatMessage, ReplyRef};
|
use concord::cord03::{self, ChatMessage, ReplyRef};
|
||||||
use concord::cord04::roles::{Permissions, citation_ok};
|
use concord::cord04::roles::{Permissions, citation_ok};
|
||||||
use concord::derive::channel_group_key;
|
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 concord::{ChannelId, CommunityId, Epoch};
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -14,6 +14,7 @@ use state::NostrRegistry;
|
|||||||
|
|
||||||
use crate::cache;
|
use crate::cache;
|
||||||
use crate::history::{self, PageRegistry, Window, WrapPage};
|
use crate::history::{self, PageRegistry, Window, WrapPage};
|
||||||
|
use crate::rekey::{self, Adoptions};
|
||||||
use crate::sync::{self, Snapshot};
|
use crate::sync::{self, Snapshot};
|
||||||
|
|
||||||
/// Wraps one relay returns for one page request.
|
/// Wraps one relay returns for one page request.
|
||||||
@@ -68,6 +69,7 @@ struct Round {
|
|||||||
pub struct SubscriptionKey {
|
pub struct SubscriptionKey {
|
||||||
control_pks: BTreeMap<u64, PublicKey>,
|
control_pks: BTreeMap<u64, PublicKey>,
|
||||||
channels: Vec<(ChannelId, Epoch, bool)>,
|
channels: Vec<(ChannelId, Epoch, bool)>,
|
||||||
|
roots: Vec<(u64, [u8; 32])>,
|
||||||
relays: Vec<RelayUrl>,
|
relays: Vec<RelayUrl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +82,11 @@ impl SubscriptionKey {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|channel| (channel.id, channel.epoch, channel.private))
|
.map(|channel| (channel.id, channel.epoch, channel.private))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
roots: state
|
||||||
|
.roots()
|
||||||
|
.into_iter()
|
||||||
|
.map(|root| (root.epoch.0, root.key))
|
||||||
|
.collect(),
|
||||||
relays: state.relays.clone(),
|
relays: state.relays.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +120,8 @@ pub struct Community {
|
|||||||
banner_task: Option<Task<Result<()>>>,
|
banner_task: Option<Task<Result<()>>>,
|
||||||
rounds: HashMap<ChannelId, Round>,
|
rounds: HashMap<ChannelId, Round>,
|
||||||
pages: PageRegistry,
|
pages: PageRegistry,
|
||||||
|
rekey_task: Option<Task<Result<()>>>,
|
||||||
|
rekey_dirty: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<CommunityEvent> for Community {}
|
impl EventEmitter<CommunityEvent> for Community {}
|
||||||
@@ -134,6 +143,8 @@ impl Community {
|
|||||||
banner_task: None,
|
banner_task: None,
|
||||||
rounds: HashMap::new(),
|
rounds: HashMap::new(),
|
||||||
pages,
|
pages,
|
||||||
|
rekey_task: None,
|
||||||
|
rekey_dirty: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +201,17 @@ impl Community {
|
|||||||
&self.members
|
&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] {
|
pub fn channels(&self) -> &[ChannelKeyRef] {
|
||||||
&self.state.channels
|
&self.state.channels
|
||||||
}
|
}
|
||||||
@@ -198,7 +220,7 @@ impl Community {
|
|||||||
SubscriptionKey::of(&self.state)
|
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])> {
|
fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> {
|
||||||
let held = self
|
let held = self
|
||||||
.state
|
.state
|
||||||
@@ -206,16 +228,13 @@ impl Community {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|held| held.id == *channel)?;
|
.find(|held| held.id == *channel)?;
|
||||||
|
|
||||||
if held.private {
|
held.current()
|
||||||
return held.key.map(|key| (held.epoch, key));
|
.or_else(|| (!held.private).then_some((held.epoch, self.state.community_root)))
|
||||||
}
|
|
||||||
|
|
||||||
Some((held.epoch, self.state.community_root))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every secret the client holds for a channel, newest epoch first.
|
/// Every secret the client holds for a channel, newest epoch first.
|
||||||
fn held_keys(&self, channel: &ChannelId) -> Vec<(Epoch, [u8; 32])> {
|
fn held_keys(&self, channel: &ChannelId) -> Vec<HeldKey> {
|
||||||
self.channel_secret(channel).into_iter().collect()
|
self.state.held_keys(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync_channel(
|
pub fn sync_channel(
|
||||||
@@ -493,6 +512,149 @@ impl Community {
|
|||||||
self.state = state;
|
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.
|
/// Rebuilds the community from the wraps in the local database.
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.refresh_task.is_some() {
|
if self.refresh_task.is_some() {
|
||||||
@@ -609,7 +771,7 @@ async fn sync_round(
|
|||||||
client: &Client,
|
client: &Client,
|
||||||
pages: &PageRegistry,
|
pages: &PageRegistry,
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
held: &[(Epoch, [u8; 32])],
|
held: &[HeldKey],
|
||||||
relays: &[RelayUrl],
|
relays: &[RelayUrl],
|
||||||
saved: ChannelCursor,
|
saved: ChannelCursor,
|
||||||
intent: Intent,
|
intent: Intent,
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use concord::cord01::KIND_WRAP_EPHEMERAL;
|
use concord::cord01::KIND_WRAP_EPHEMERAL;
|
||||||
use concord::cord03::{self, ChatRumor, plane_keys};
|
use concord::cord03::{self, ChatRumor};
|
||||||
use concord::state::ChannelCursor;
|
use concord::derive::channel_group_key;
|
||||||
use concord::{ChannelId, Epoch};
|
use concord::state::{ChannelCursor, HeldKey};
|
||||||
|
use concord::{ChannelId, GroupKey};
|
||||||
use futures::future::{Either, select};
|
use futures::future::{Either, select};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
@@ -149,13 +150,16 @@ pub async fn page(
|
|||||||
client: &Client,
|
client: &Client,
|
||||||
pages: &PageRegistry,
|
pages: &PageRegistry,
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
held: &[(Epoch, [u8; 32])],
|
held: &[HeldKey],
|
||||||
relays: &[RelayUrl],
|
relays: &[RelayUrl],
|
||||||
window: Window,
|
window: Window,
|
||||||
max_pages: usize,
|
max_pages: usize,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<WrapPage> {
|
) -> 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();
|
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
|
||||||
|
|
||||||
if authors.is_empty() || relays.is_empty() || limit == 0 {
|
if authors.is_empty() || relays.is_empty() || limit == 0 {
|
||||||
@@ -201,12 +205,20 @@ pub async fn page(
|
|||||||
let answered = client.database().query(filter).await?;
|
let answered = client.database().query(filter).await?;
|
||||||
|
|
||||||
for wrap in walk.accept(answered, limit) {
|
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 {
|
else {
|
||||||
continue;
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -332,7 +344,6 @@ fn history_subscription() -> SubscriptionId {
|
|||||||
SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed)))
|
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>
|
async fn within<F>(limit: Duration, future: F) -> Option<F::Output>
|
||||||
where
|
where
|
||||||
F: Future,
|
F: Future,
|
||||||
@@ -460,6 +471,7 @@ impl Walk {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::cmp::Reverse;
|
use std::cmp::Reverse;
|
||||||
|
|
||||||
|
use concord::Epoch;
|
||||||
use concord::cord03::{build_message, seal_rumor};
|
use concord::cord03::{build_message, seal_rumor};
|
||||||
use concord::derive::channel_group_key;
|
use concord::derive::channel_group_key;
|
||||||
|
|
||||||
@@ -492,8 +504,27 @@ mod tests {
|
|||||||
fn a_walk_pages_back_across_a_rekey() {
|
fn a_walk_pages_back_across_a_rekey() {
|
||||||
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
||||||
let author = Keys::generate();
|
let author = Keys::generate();
|
||||||
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
|
let held = [
|
||||||
let planes = plane_keys(&held, &channel).expect("derives");
|
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.
|
// Three messages a second apart: a page boundary falls between each.
|
||||||
let base = 1_700_000_000_000;
|
let base = 1_700_000_000_000;
|
||||||
@@ -530,13 +561,13 @@ mod tests {
|
|||||||
let page = serve_page(&relay, walk.region(), 2);
|
let page = serve_page(&relay, walk.region(), 2);
|
||||||
|
|
||||||
for wrap in walk.accept(page, 2) {
|
for wrap in walk.accept(page, 2) {
|
||||||
let Some((epoch, group)) =
|
let Some((held, group)) =
|
||||||
planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||||
else {
|
else {
|
||||||
continue;
|
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);
|
found.push(rumor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-9
@@ -13,10 +13,12 @@ use smallvec::{SmallVec, smallvec};
|
|||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
|
|
||||||
use crate::history::{PageRegistry, Settled, auth_required};
|
use crate::history::{PageRegistry, Settled, auth_required};
|
||||||
|
use crate::rekey::WatchRegistry;
|
||||||
|
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
mod community;
|
mod community;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
mod rekey;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
|
||||||
pub use community::*;
|
pub use community::*;
|
||||||
@@ -37,6 +39,7 @@ impl Global for GlobalCommunityRegistry {}
|
|||||||
enum Signal {
|
enum Signal {
|
||||||
Event(CommunityId),
|
Event(CommunityId),
|
||||||
List,
|
List,
|
||||||
|
Rekey(CommunityId),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which standing subscription an id belongs to.
|
/// Which standing subscription an id belongs to.
|
||||||
@@ -59,6 +62,7 @@ fn route_of(id: &SubscriptionId) -> Option<Route> {
|
|||||||
struct Batch {
|
struct Batch {
|
||||||
list: bool,
|
list: bool,
|
||||||
communities: BTreeSet<CommunityId>,
|
communities: BTreeSet<CommunityId>,
|
||||||
|
rekeys: BTreeSet<CommunityId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the pump should keep listening.
|
/// 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.
|
/// 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 {
|
match notification {
|
||||||
ClientNotification::Event {
|
ClientNotification::Event {
|
||||||
subscription_id, ..
|
subscription_id, ..
|
||||||
} => match route_of(&subscription_id) {
|
} => match route_of(&subscription_id) {
|
||||||
Some(Route::List) => batch.list = true,
|
Some(Route::List) => batch.list = true,
|
||||||
// Only the database is told here; the fold reads it when the window closes.
|
|
||||||
Some(Route::Community(id)) => {
|
Some(Route::Community(id)) => {
|
||||||
batch.communities.insert(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 {
|
ClientNotification::Message { relay_url, message } => match *message {
|
||||||
RelayMessage::EndOfStoredEvents(id) => {
|
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?;
|
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) {
|
if std::mem::take(&mut batch.list) {
|
||||||
tx.send_async(Signal::List).await?;
|
tx.send_async(Signal::List).await?;
|
||||||
}
|
}
|
||||||
@@ -129,6 +145,8 @@ pub struct CommunityRegistry {
|
|||||||
signal_rx: flume::Receiver<Signal>,
|
signal_rx: flume::Receiver<Signal>,
|
||||||
/// The page subscriptions in flight, shared with the notification pump.
|
/// The page subscriptions in flight, shared with the notification pump.
|
||||||
pages: PageRegistry,
|
pages: PageRegistry,
|
||||||
|
/// The rekey watch subscriptions, resolved to their community.
|
||||||
|
watches: WatchRegistry,
|
||||||
tasks: SmallVec<[Task<Result<()>>; 2]>,
|
tasks: SmallVec<[Task<Result<()>>; 2]>,
|
||||||
/// Notification listener task (cancelled on signer change)
|
/// Notification listener task (cancelled on signer change)
|
||||||
notification_listener: Option<Task<Result<()>>>,
|
notification_listener: Option<Task<Result<()>>>,
|
||||||
@@ -181,6 +199,7 @@ impl CommunityRegistry {
|
|||||||
signal_tx: tx,
|
signal_tx: tx,
|
||||||
signal_rx: rx,
|
signal_rx: rx,
|
||||||
pages: PageRegistry::default(),
|
pages: PageRegistry::default(),
|
||||||
|
watches: WatchRegistry::default(),
|
||||||
tasks: smallvec![],
|
tasks: smallvec![],
|
||||||
notification_listener: None,
|
notification_listener: None,
|
||||||
signal_consumer: None,
|
signal_consumer: None,
|
||||||
@@ -256,6 +275,7 @@ impl CommunityRegistry {
|
|||||||
self.tasks.clear();
|
self.tasks.clear();
|
||||||
self.observers.clear();
|
self.observers.clear();
|
||||||
self.pages.clear();
|
self.pages.clear();
|
||||||
|
self.watches.clear();
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
@@ -264,9 +284,11 @@ impl CommunityRegistry {
|
|||||||
for id in ids {
|
for id in ids {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let subscription = sync::subscription_id(&id);
|
let subscription = sync::subscription_id(&id);
|
||||||
|
let rekey = rekey::subscription_id(&id);
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
client.unsubscribe(&subscription).await?;
|
client.unsubscribe(&subscription).await?;
|
||||||
|
client.unsubscribe(&rekey).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -274,6 +296,7 @@ impl CommunityRegistry {
|
|||||||
self.communities.clear();
|
self.communities.clear();
|
||||||
self.index.clear();
|
self.index.clear();
|
||||||
self.synced.clear();
|
self.synced.clear();
|
||||||
|
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +415,15 @@ impl CommunityRegistry {
|
|||||||
community.update(cx, |community, cx| community.refresh(cx));
|
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.
|
/// Re-subscribe every community whose held planes moved.
|
||||||
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
|
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -424,6 +456,18 @@ impl CommunityRegistry {
|
|||||||
let filter = sync::live_filter(&planes, sync::live_window(&state));
|
let filter = sync::live_filter(&planes, sync::live_window(&state));
|
||||||
let relays = key.relays().to_vec();
|
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.synced.insert(id, key);
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
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(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -448,6 +498,7 @@ impl CommunityRegistry {
|
|||||||
let tx = self.signal_tx.clone();
|
let tx = self.signal_tx.clone();
|
||||||
let rx = self.signal_rx.clone();
|
let rx = self.signal_rx.clone();
|
||||||
let pages = self.pages.clone();
|
let pages = self.pages.clone();
|
||||||
|
let watches = self.watches.clone();
|
||||||
let executor = cx.background_executor().clone();
|
let executor = cx.background_executor().clone();
|
||||||
|
|
||||||
self.notification_listener = Some(cx.background_spawn(async move {
|
self.notification_listener = Some(cx.background_spawn(async move {
|
||||||
@@ -457,7 +508,7 @@ impl CommunityRegistry {
|
|||||||
'outer: loop {
|
'outer: loop {
|
||||||
match notifications.next().await {
|
match notifications.next().await {
|
||||||
Some(notification) => {
|
Some(notification) => {
|
||||||
if route(notification, &pages, &mut batch) == Flow::Stop {
|
if route(notification, &pages, &watches, &mut batch) == Flow::Stop {
|
||||||
flush(&tx, &mut batch).await?;
|
flush(&tx, &mut batch).await?;
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
@@ -481,7 +532,7 @@ impl CommunityRegistry {
|
|||||||
|
|
||||||
match select(next, timer).await {
|
match select(next, timer).await {
|
||||||
Either::Left((Some(notification), _)) => {
|
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?;
|
flush(&tx, &mut batch).await?;
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
@@ -501,6 +552,7 @@ impl CommunityRegistry {
|
|||||||
while let Ok(signal) = rx.recv_async().await {
|
while let Ok(signal) = rx.recv_async().await {
|
||||||
match signal {
|
match signal {
|
||||||
Signal::Event(id) => this.update(cx, |this, cx| this.refresh(id, cx))?,
|
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))?,
|
Signal::List => this.update(cx, |this, cx| this.load(cx))?,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -605,25 +657,53 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_burst_collapses_to_one_signal_per_community() {
|
fn a_burst_collapses_to_one_signal_per_community() {
|
||||||
let pages = PageRegistry::default();
|
let pages = PageRegistry::default();
|
||||||
|
let watches = WatchRegistry::default();
|
||||||
let community = CommunityId::from_bytes([0x42; 32]);
|
let community = CommunityId::from_bytes([0x42; 32]);
|
||||||
let mut batch = Batch::default();
|
let mut batch = Batch::default();
|
||||||
|
|
||||||
let plane = event(sync::subscription_id(&community));
|
let plane = event(sync::subscription_id(&community));
|
||||||
for _ in 0..50 {
|
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.
|
// A page's event is folded from the database later, not routed here.
|
||||||
route(
|
route(
|
||||||
event(SubscriptionId::new("concord-history-7")),
|
event(SubscriptionId::new("concord-history-7")),
|
||||||
&pages,
|
&pages,
|
||||||
|
&watches,
|
||||||
&mut batch,
|
&mut batch,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(batch.list);
|
assert!(batch.list);
|
||||||
assert_eq!(batch.communities, BTreeSet::from([community]));
|
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]
|
#[test]
|
||||||
@@ -637,7 +717,12 @@ mod tests {
|
|||||||
pages.register(other, other_tx);
|
pages.register(other, other_tx);
|
||||||
|
|
||||||
let mut batch = Batch::default();
|
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_eq!(flow, Flow::Continue);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -658,6 +743,7 @@ mod tests {
|
|||||||
route(
|
route(
|
||||||
message(RelayMessage::closed(id, "blocked: not allowed")),
|
message(RelayMessage::closed(id, "blocked: not allowed")),
|
||||||
&pages,
|
&pages,
|
||||||
|
&WatchRegistry::default(),
|
||||||
&mut batch,
|
&mut batch,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -683,6 +769,7 @@ mod tests {
|
|||||||
"auth-required: please authenticate",
|
"auth-required: please authenticate",
|
||||||
)),
|
)),
|
||||||
&pages,
|
&pages,
|
||||||
|
&WatchRegistry::default(),
|
||||||
&mut batch,
|
&mut batch,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -695,7 +782,12 @@ mod tests {
|
|||||||
let mut batch = Batch::default();
|
let mut batch = Batch::default();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
route(ClientNotification::Shutdown, &pages, &mut batch),
|
route(
|
||||||
|
ClientNotification::Shutdown,
|
||||||
|
&pages,
|
||||||
|
&WatchRegistry::default(),
|
||||||
|
&mut batch
|
||||||
|
),
|
||||||
Flow::Stop
|
Flow::Stop
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -10,7 +10,7 @@ use concord::cord04::roles::{Permissions, citation_ok};
|
|||||||
use concord::derive::{
|
use concord::derive::{
|
||||||
channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key,
|
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 concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||||
use gpui::AsyncApp;
|
use gpui::AsyncApp;
|
||||||
use nostr_sdk::prelude::*;
|
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.
|
/// The wrap's author: the control signer for Control, the group's own key otherwise.
|
||||||
pub address: PublicKey,
|
pub address: PublicKey,
|
||||||
pub group: GroupKey,
|
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>> {
|
pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
|
||||||
let mut planes = Vec::new();
|
let mut planes = Vec::new();
|
||||||
|
let roots = state.roots();
|
||||||
|
|
||||||
for (epoch, address) in &state.control_pks {
|
for (epoch, address) in &state.control_pks {
|
||||||
let epoch = Epoch(*epoch);
|
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 {
|
planes.push(Plane {
|
||||||
kind: PlaneKind::Control(epoch),
|
kind: PlaneKind::Control(epoch),
|
||||||
address: *address,
|
address: *address,
|
||||||
group,
|
group,
|
||||||
|
retired_at: root.retired_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,31 +80,38 @@ pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
|
|||||||
kind: PlaneKind::Control(state.root_epoch),
|
kind: PlaneKind::Control(state.root_epoch),
|
||||||
address: group.pk(),
|
address: group.pk(),
|
||||||
group,
|
group,
|
||||||
|
retired_at: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?;
|
for root in &roots {
|
||||||
planes.push(Plane {
|
let group = guestbook_group_key(&root.key, &state.id, root.epoch)?;
|
||||||
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)?;
|
|
||||||
planes.push(Plane {
|
planes.push(Plane {
|
||||||
kind: PlaneKind::Channel(channel.id, channel.epoch),
|
kind: PlaneKind::Guestbook,
|
||||||
address: group.pk(),
|
address: group.pk(),
|
||||||
group,
|
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)
|
Ok(planes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,17 +420,44 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
|
|||||||
held.relays = fresh.relays;
|
held.relays = fresh.relays;
|
||||||
|
|
||||||
for channel in fresh.channels {
|
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) {
|
match held.channels.iter_mut().find(|held| held.id == channel.id) {
|
||||||
Some(held) => {
|
Some(held) => {
|
||||||
held.name = channel.name;
|
held.name = channel.name;
|
||||||
held.epoch = channel.epoch;
|
|
||||||
|
if cut.is_some_and(|cut| channel.epoch.0 <= cut) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if channel.private {
|
if channel.private {
|
||||||
held.private = true;
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A retired key reads only what was sealed before its rotation published.
|
||||||
|
if !plane.accepts(wrap) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
match plane.kind {
|
match plane.kind {
|
||||||
PlaneKind::Control(_) => {
|
PlaneKind::Control(_) => {
|
||||||
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
|
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
|
||||||
@@ -609,6 +672,7 @@ mod tests {
|
|||||||
private: false,
|
private: false,
|
||||||
epoch: Epoch(0),
|
epoch: Epoch(0),
|
||||||
key: None,
|
key: None,
|
||||||
|
priors: Vec::new(),
|
||||||
},
|
},
|
||||||
concord::state::ChannelKeyRef {
|
concord::state::ChannelKeyRef {
|
||||||
id: staff,
|
id: staff,
|
||||||
@@ -616,6 +680,7 @@ mod tests {
|
|||||||
private: true,
|
private: true,
|
||||||
epoch: Epoch(0),
|
epoch: Epoch(0),
|
||||||
key: Some([0x04; 32]),
|
key: Some([0x04; 32]),
|
||||||
|
priors: Vec::new(),
|
||||||
},
|
},
|
||||||
concord::state::ChannelKeyRef {
|
concord::state::ChannelKeyRef {
|
||||||
id: ChannelId::from_bytes([0x9e; 32]),
|
id: ChannelId::from_bytes([0x9e; 32]),
|
||||||
@@ -623,12 +688,17 @@ mod tests {
|
|||||||
private: true,
|
private: true,
|
||||||
epoch: Epoch(0),
|
epoch: Epoch(0),
|
||||||
key: None,
|
key: None,
|
||||||
|
priors: Vec::new(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")],
|
relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")],
|
||||||
heads: Vec::new(),
|
heads: Vec::new(),
|
||||||
banned: BTreeSet::new(),
|
banned: BTreeSet::new(),
|
||||||
cursors: BTreeMap::new(),
|
cursors: BTreeMap::new(),
|
||||||
|
held_roots: Vec::new(),
|
||||||
|
channel_cuts: BTreeMap::new(),
|
||||||
|
removed_at: None,
|
||||||
|
stranded: false,
|
||||||
dissolved: false,
|
dissolved: false,
|
||||||
added_at_ms: 0,
|
added_at_ms: 0,
|
||||||
};
|
};
|
||||||
@@ -719,11 +789,16 @@ mod tests {
|
|||||||
private: false,
|
private: false,
|
||||||
epoch: Epoch(0),
|
epoch: Epoch(0),
|
||||||
key: None,
|
key: None,
|
||||||
|
priors: Vec::new(),
|
||||||
}],
|
}],
|
||||||
relays: Vec::new(),
|
relays: Vec::new(),
|
||||||
heads: Vec::new(),
|
heads: Vec::new(),
|
||||||
banned: BTreeSet::new(),
|
banned: BTreeSet::new(),
|
||||||
cursors: BTreeMap::new(),
|
cursors: BTreeMap::new(),
|
||||||
|
held_roots: Vec::new(),
|
||||||
|
channel_cuts: BTreeMap::new(),
|
||||||
|
removed_at: None,
|
||||||
|
stranded: false,
|
||||||
dissolved: false,
|
dissolved: false,
|
||||||
added_at_ms: 1_700_000_000_000,
|
added_at_ms: 1_700_000_000_000,
|
||||||
}
|
}
|
||||||
@@ -1021,4 +1096,45 @@ mod tests {
|
|||||||
|
|
||||||
assert!(subscription_id(&id).as_str().len() <= 64);
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,27 @@ use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
|||||||
/// The `concord/` namespace for locally-keyed documents.
|
/// The `concord/` namespace for locally-keyed documents.
|
||||||
pub const STATE_PREFIX: &str = "concord/";
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ChannelKeyRef {
|
pub struct ChannelKeyRef {
|
||||||
pub id: ChannelId,
|
pub id: ChannelId,
|
||||||
@@ -26,6 +47,16 @@ pub struct ChannelKeyRef {
|
|||||||
/// The channel's read secret when the member was granted it.
|
/// The channel's read secret when the member was granted it.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub key: Option<[u8; 32]>,
|
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.
|
/// 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.
|
/// Where each channel's history sync has reached.
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
|
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)]
|
#[serde(default)]
|
||||||
pub dissolved: bool,
|
pub dissolved: bool,
|
||||||
pub added_at_ms: u64,
|
pub added_at_ms: u64,
|
||||||
@@ -135,6 +179,7 @@ impl CommunityState {
|
|||||||
private: metadata.private,
|
private: metadata.private,
|
||||||
epoch: ROOT_EPOCH,
|
epoch: ROOT_EPOCH,
|
||||||
key: None,
|
key: None,
|
||||||
|
priors: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -165,6 +210,10 @@ impl CommunityState {
|
|||||||
heads,
|
heads,
|
||||||
banned: BTreeSet::new(),
|
banned: BTreeSet::new(),
|
||||||
cursors: BTreeMap::new(),
|
cursors: BTreeMap::new(),
|
||||||
|
held_roots: Vec::new(),
|
||||||
|
channel_cuts: BTreeMap::new(),
|
||||||
|
removed_at: None,
|
||||||
|
stranded: false,
|
||||||
dissolved: false,
|
dissolved: false,
|
||||||
added_at_ms,
|
added_at_ms,
|
||||||
})
|
})
|
||||||
@@ -177,6 +226,7 @@ impl CommunityState {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut channels = Vec::with_capacity(material.channels.len());
|
let mut channels = Vec::with_capacity(material.channels.len());
|
||||||
|
|
||||||
for grant in &material.channels {
|
for grant in &material.channels {
|
||||||
let key = match &grant.key {
|
let key = match &grant.key {
|
||||||
Some(key) => Some(decode_hex_32(key)?),
|
Some(key) => Some(decode_hex_32(key)?),
|
||||||
@@ -189,6 +239,7 @@ impl CommunityState {
|
|||||||
private: key.is_some(),
|
private: key.is_some(),
|
||||||
epoch: grant.epoch,
|
epoch: grant.epoch,
|
||||||
key,
|
key,
|
||||||
|
priors: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +264,10 @@ impl CommunityState {
|
|||||||
heads: Vec::new(),
|
heads: Vec::new(),
|
||||||
banned: BTreeSet::new(),
|
banned: BTreeSet::new(),
|
||||||
cursors: BTreeMap::new(),
|
cursors: BTreeMap::new(),
|
||||||
|
held_roots: Vec::new(),
|
||||||
|
channel_cuts: BTreeMap::new(),
|
||||||
|
removed_at: None,
|
||||||
|
stranded: false,
|
||||||
dissolved: false,
|
dissolved: false,
|
||||||
added_at_ms,
|
added_at_ms,
|
||||||
})
|
})
|
||||||
@@ -222,6 +277,58 @@ impl CommunityState {
|
|||||||
state_identifier(&self.id)
|
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 {
|
pub fn floors(&self) -> Floors {
|
||||||
self.heads
|
self.heads
|
||||||
.iter()
|
.iter()
|
||||||
@@ -265,6 +372,7 @@ impl CommunityState {
|
|||||||
private: false,
|
private: false,
|
||||||
epoch: self.root_epoch,
|
epoch: self.root_epoch,
|
||||||
key: None,
|
key: None,
|
||||||
|
priors: Vec::new(),
|
||||||
}),
|
}),
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
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
|
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
|
CommunityPanel::load community_ui/src/lib.rs:192
|
||||||
@@ -60,10 +61,17 @@ live wire community/src/lib.rs:309 (sync_subscriptions)
|
|||||||
private channels,
|
private channels,
|
||||||
Window::opening,
|
Window::opening,
|
||||||
limit LIVE_REPLAY)
|
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
|
-> pump community/src/lib.rs:433 (routes by subscription
|
||||||
id, batches within
|
id, batches within
|
||||||
PUMP_WINDOW, settles
|
PUMP_WINDOW, settles
|
||||||
pages by EOSE/CLOSED)
|
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
|
## 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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 |
|
| 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** |
|
| 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 |
|
| 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_of(&SubscriptionId) -> Option<Route>` parses the two **standing** ids into
|
||||||
`Route::{List, Community(CommunityId)}`, so the pump routes an event by
|
`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 }`
|
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
|
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
|
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
|
#### The pump
|
||||||
|
|
||||||
@@ -412,8 +423,9 @@ pub fn timeline(&self, channel: &ChannelId, before_ms: Option<u64>, limit: usize
|
|||||||
|
|
||||||
### 5. Live completeness
|
### 5. Live completeness
|
||||||
|
|
||||||
- **Private channels get a plane.** `sync::planes` derives it from the held key
|
- **Private channels get a plane.** `sync::planes` derives one per held epoch
|
||||||
(`channel_secret` already knows the rule); a private channel is subscribed and
|
(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).
|
folded exactly like a public one (finding 5).
|
||||||
- **Both wrap kinds.** The filter asks for `KIND_WRAP` and `KIND_WRAP_EPHEMERAL`
|
- **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).
|
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
|
twice then has one addressable coordinate, so `save_event` replaces its
|
||||||
predecessor instead of adding a copy per app run.
|
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 client has to be able to read the epochs it is supposed to read, and learn
|
||||||
the next ones:
|
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
|
epoch that predates my join and carries no blob for me, which says the invite
|
||||||
link is out of date).
|
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)
|
### 7. Honest states (phase 4)
|
||||||
|
|
||||||
- `Progress` already carries `fetched`, `opened`, `exhausted`, `failed`, `errors`;
|
- `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
|
Also landed as the cheap win §3 promised: the older pass is skipped entirely when
|
||||||
`saved.exhausted` is already set.
|
`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
|
1. The document (§6): `HeldKey`/`HeldRoot`, `ChannelKeyRef.priors`,
|
||||||
watch over every held root, strict one-epoch-at-a-time adoption, re-subscribe +
|
`CommunityState.held_roots`/`channel_cuts`/`removed_at`/`stranded`, and the
|
||||||
`CatchUp` after a delivery, removed versus stranded states.
|
`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
|
### Phase 4 — honest states and polish
|
||||||
|
|
||||||
§7 (empty/unreadable/failed in the panel, using the counts that already exist),
|
§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`
|
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
|
supports negentropy; "negentropy unsupported" means "fall back to the paged
|
||||||
walk", never "exhausted").
|
walk", never "exhausted").
|
||||||
|
|
||||||
Each phase leaves the client consistent on its own. Phase 2a was invisible; phase
|
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
|
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.
|
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)
|
## 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
|
... 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
|
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.
|
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
|
- Held epoch handling and rekeys landed in §6 (phase 3); honest empty states
|
||||||
§7. Private planes, the second wrap kind, the expired-row sweep, the stable
|
remain open in §7. Private planes, the second wrap kind, the expired-row sweep,
|
||||||
cache key and the fold cost were open before phase 1 and are now landed (§5,
|
the stable cache key and the fold cost were open before phase 1 and are now
|
||||||
§9).
|
landed (§5, §9).
|
||||||
|
|
||||||
## Tests
|
## 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.
|
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
|
- The page registry — **landed in 2b**: a page that has already unregistered
|
||||||
receives nothing.
|
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 read path: the side-event budget folds an edit/delete/reaction older than
|
||||||
the row window onto its message.
|
the row window onto its message.
|
||||||
- The fold: a new live wrap costs one decrypt, and a fold over a community with
|
- The fold: a new live wrap costs one decrypt, and a fold over a community with
|
||||||
|
|||||||
+42
-10
@@ -224,6 +224,11 @@ Relay history pages through the local cache:
|
|||||||
```rust
|
```rust
|
||||||
use community::history::{self, PageRegistry, Window};
|
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
|
// 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.
|
// what carries each page's EOSE and CLOSED back to the round waiting on it.
|
||||||
let page = history::page(
|
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?;
|
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
|
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
|
`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,
|
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`
|
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`.
|
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
|
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.
|
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.
|
`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.
|
rekey fold changes it.
|
||||||
- Route inbound events by `subscription_id` from `RelayMessage::Event`, never by
|
- Route inbound events by `subscription_id` from `RelayMessage::Event`, never by
|
||||||
kind.
|
kind.
|
||||||
- Watch one epoch ahead: while holding `root_N`, subscribe to
|
- Watch one epoch ahead for the base, and a **window** of channel epochs under
|
||||||
`base_rekey_group_key(&root_N, &community_id, Epoch(N + 1))` and to
|
every held root for private channels: `base_rekey_group_key(&root_N, &id,
|
||||||
`channel_rekey_group_key(&root_N, &channel, Epoch(N + 1))` for each private
|
Epoch(N + 1))`, plus `channel_rekey_group_key(&root, &channel, Epoch(channel_epoch
|
||||||
channel. A second epoch ahead is not derivable until the new root arrives.
|
+ 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
|
### 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
|
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
|
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.
|
live, or every stream wrap lands in the DM trash and raises a toast.
|
||||||
- **Rotation-delivered plane keys cannot be persisted yet.** `CommunityState` has
|
- **Rotation-delivered plane keys are persisted, and history spans them.**
|
||||||
nowhere to keep a key a rotation delivered, so a client can verify a rotation
|
`CommunityState.held_roots` keeps every root a rotation stepped off (with the
|
||||||
and still lose it on restart — history under a prior root or a prior channel
|
retired root's Control signer and the publish time that retires it), and each
|
||||||
epoch is unreadable until that schema change lands. (A granted private-channel
|
`ChannelKeyRef.priors` keeps every channel key a rotation stepped off. The read
|
||||||
key does now have a home: `ChannelKeyRef.key`, filled by
|
side derives planes from all of them, and a retired key's `retired_at` is a hard
|
||||||
`CommunityState::from_join_material`.)
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user