updat
This commit is contained in:
Generated
-2
@@ -1300,7 +1300,6 @@ dependencies = [
|
|||||||
"common",
|
"common",
|
||||||
"community",
|
"community",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"log",
|
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"person",
|
"person",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -1338,7 +1337,6 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
"log",
|
|
||||||
"nostr",
|
"nostr",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ use std::time::{Duration, Instant};
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use concord::cord02::{ControlFold, ImageRef};
|
use concord::cord02::{ControlFold, ImageRef};
|
||||||
use concord::cord03::{self, ChatMessage, ReplyRef};
|
use concord::cord03::{self, ChatMessage, ReplyRef};
|
||||||
|
use concord::cord04::AuthorityCitation;
|
||||||
use concord::cord04::roles::{Permissions, citation_ok};
|
use concord::cord04::roles::{Permissions, citation_ok};
|
||||||
use concord::derive::channel_group_key;
|
use concord::cord06::RekeyScope;
|
||||||
|
use concord::derive::{channel_group_key, grant_locator};
|
||||||
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot};
|
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};
|
||||||
@@ -255,7 +257,7 @@ impl Community {
|
|||||||
|
|
||||||
/// The epoch a channel rotation removed us at, when it removed us.
|
/// The epoch a channel rotation removed us at, when it removed us.
|
||||||
pub fn channel_removed_at(&self, channel: &ChannelId) -> Option<Epoch> {
|
pub fn channel_removed_at(&self, channel: &ChannelId) -> Option<Epoch> {
|
||||||
self.state.channel_cuts.get(channel).map(|cut| Epoch(*cut))
|
self.state.channel_cuts.get(channel).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an automatic catch-up for `channel` is worth asking for yet.
|
/// Whether an automatic catch-up for `channel` is worth asking for yet.
|
||||||
@@ -653,6 +655,78 @@ impl Community {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rotate one scope to its next epoch, cutting off `excluded`.
|
||||||
|
pub fn rotate(
|
||||||
|
&mut self,
|
||||||
|
scope: RekeyScope,
|
||||||
|
recipients: &[PublicKey],
|
||||||
|
excluded: &[PublicKey],
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Option<Task<Result<Epoch>>> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let me = nostr.read(cx).current_user()?;
|
||||||
|
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
|
if self.state.removed_at.is_some() || self.state.stranded || self.state.banned.contains(&me)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rewrite = rekey::Rewrite {
|
||||||
|
scope,
|
||||||
|
recipients: recipients.to_vec(),
|
||||||
|
excluded: excluded.to_vec(),
|
||||||
|
citation: self.citation(&me),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !rewrite.authorized(&self.control.roles, &self.state.owner, &me) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = self.state.clone();
|
||||||
|
let roles = self.control.roles.clone();
|
||||||
|
|
||||||
|
Some(cx.spawn(async move |this, cx| {
|
||||||
|
let epoch = rekey::rotate(
|
||||||
|
&client,
|
||||||
|
&state,
|
||||||
|
&roles,
|
||||||
|
&signer,
|
||||||
|
me,
|
||||||
|
&rewrite,
|
||||||
|
Timestamp::now(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let adoptions = rekey::adopt(&client, &state, &roles, &signer, me).await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if !adoptions.is_empty() {
|
||||||
|
this.merge_adoptions(adoptions, cx);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(epoch)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rank this client cites when it acts.
|
||||||
|
fn citation(&self, me: &PublicKey) -> Option<AuthorityCitation> {
|
||||||
|
let entity = grant_locator(&self.state.id, &me.to_bytes());
|
||||||
|
|
||||||
|
self.state
|
||||||
|
.heads
|
||||||
|
.iter()
|
||||||
|
.find(|head| head.entity == entity)
|
||||||
|
.map(|head| AuthorityCitation {
|
||||||
|
entity: head.entity,
|
||||||
|
version: head.version,
|
||||||
|
hash: head.self_hash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_rekey(&mut self, result: Result<Adoptions>, cx: &mut Context<Self>) {
|
fn apply_rekey(&mut self, result: Result<Adoptions>, cx: &mut Context<Self>) {
|
||||||
self.rekey_task = None;
|
self.rekey_task = None;
|
||||||
|
|
||||||
@@ -691,6 +765,8 @@ impl Community {
|
|||||||
self.state.control_pks.insert(base.epoch.0, control_pk);
|
self.state.control_pks.insert(base.epoch.0, control_pk);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rotation is authoritative about the new epoch's signing root
|
||||||
|
self.state.control_root = base.control_root;
|
||||||
self.state.community_root = base.key;
|
self.state.community_root = base.key;
|
||||||
self.state.root_epoch = base.epoch;
|
self.state.root_epoch = base.epoch;
|
||||||
self.state.removed_at = None;
|
self.state.removed_at = None;
|
||||||
@@ -726,7 +802,7 @@ impl Community {
|
|||||||
|
|
||||||
for (channel, epoch) in adoptions.cuts {
|
for (channel, epoch) in adoptions.cuts {
|
||||||
self.state.channels.retain(|held| held.id != channel);
|
self.state.channels.retain(|held| held.id != channel);
|
||||||
self.state.channel_cuts.insert(channel, epoch.0);
|
self.state.channel_cuts.insert(channel, epoch);
|
||||||
self.state.cursors.remove(&channel);
|
self.state.cursors.remove(&channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,7 +1010,7 @@ async fn sync_round(
|
|||||||
Intent::Older { .. } => WrapPage::default(),
|
Intent::Older { .. } => WrapPage::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let bridge = match (intent, newest.oldest_ms, saved.newest_ms) {
|
let bridge = match (intent, newest.oldest, saved.newest) {
|
||||||
(Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => {
|
(Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => {
|
||||||
let page = history::page(
|
let page = history::page(
|
||||||
client,
|
client,
|
||||||
@@ -958,8 +1034,8 @@ async fn sync_round(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let resume = match intent {
|
let resume = match intent {
|
||||||
Intent::CatchUp => saved.oldest_ms.or(newest.oldest_ms),
|
Intent::CatchUp => saved.oldest.or(newest.oldest),
|
||||||
Intent::Older { .. } => saved.oldest_ms,
|
Intent::Older { .. } => saved.oldest,
|
||||||
};
|
};
|
||||||
|
|
||||||
let budget = match intent {
|
let budget = match intent {
|
||||||
@@ -994,16 +1070,16 @@ async fn sync_round(
|
|||||||
if intent == Intent::CatchUp {
|
if intent == Intent::CatchUp {
|
||||||
let complete = !newest.failed && bridge.exhausted;
|
let complete = !newest.failed && bridge.exhausted;
|
||||||
let top = newest
|
let top = newest
|
||||||
.newest_ms
|
.newest
|
||||||
.unwrap_or(0)
|
.unwrap_or_default()
|
||||||
.max(bridge.newest_ms.unwrap_or(0));
|
.max(bridge.newest.unwrap_or_default());
|
||||||
|
|
||||||
if complete && top > 0 {
|
if complete && !top.is_zero() {
|
||||||
round.newest_ms = Some(top);
|
round.newest = Some(top);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
round.oldest_ms = older.oldest_ms.or(newest.oldest_ms);
|
round.oldest = older.oldest.or(newest.oldest);
|
||||||
round.exhausted = older.exhausted;
|
round.exhausted = older.exhausted;
|
||||||
|
|
||||||
Ok((progress, round))
|
Ok((progress, round))
|
||||||
@@ -1082,7 +1158,7 @@ mod tests {
|
|||||||
|
|
||||||
// ...and a channel cut closes the one channel.
|
// ...and a channel cut closes the one channel.
|
||||||
let mut cut = state(channel);
|
let mut cut = state(channel);
|
||||||
cut.channel_cuts.insert(channel, 0);
|
cut.channel_cuts.insert(channel, Epoch(0));
|
||||||
assert_eq!(community(cut).channel_secret(&channel), None);
|
assert_eq!(community(cut).channel_secret(&channel), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ use crate::sync::connect_relays;
|
|||||||
/// How long one relay is given to answer one page of history.
|
/// How long one relay is given to answer one page of history.
|
||||||
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
/// How far below a cursor a warm window reaches back.
|
/// How far below a cursor a warm window reaches back.
|
||||||
pub const CURSOR_OVERLAP_MS: u64 = 60_000;
|
pub const CURSOR_OVERLAP: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
/// The region of history to read.
|
/// The region of history to read.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct Window {
|
pub struct Window {
|
||||||
pub until_ms: Option<u64>,
|
pub until: Option<Timestamp>,
|
||||||
pub since_ms: Option<u64>,
|
pub since: Option<Timestamp>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Window {
|
impl Window {
|
||||||
@@ -34,28 +34,28 @@ impl Window {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wraps strictly older than `oldest_ms`.
|
/// The wraps strictly older than `oldest`.
|
||||||
pub fn older_than(oldest_ms: u64) -> Self {
|
pub fn older_than(oldest: Timestamp) -> Self {
|
||||||
Self {
|
Self {
|
||||||
until_ms: Some(oldest_ms.saturating_sub(1)),
|
until: Some(oldest - 1u64),
|
||||||
since_ms: None,
|
since: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The region between `since_ms` and `oldest_ms`, both inclusive.
|
/// The region between `since` and `oldest`, both inclusive.
|
||||||
pub fn between(since_ms: u64, oldest_ms: u64) -> Self {
|
pub fn between(since: Timestamp, oldest: Timestamp) -> Self {
|
||||||
Self {
|
Self {
|
||||||
until_ms: Some(oldest_ms.saturating_sub(1)),
|
until: Some(oldest - 1u64),
|
||||||
since_ms: Some(since_ms),
|
since: Some(since),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The window a channel is opened with.
|
/// The window a channel is opened with.
|
||||||
pub fn opening(cursor: ChannelCursor) -> Self {
|
pub fn opening(cursor: ChannelCursor) -> Self {
|
||||||
match cursor.newest_ms {
|
match cursor.newest {
|
||||||
Some(newest_ms) => Self {
|
Some(newest) => Self {
|
||||||
since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
|
since: Some(newest - CURSOR_OVERLAP),
|
||||||
until_ms: None,
|
until: None,
|
||||||
},
|
},
|
||||||
None => Self::default(),
|
None => Self::default(),
|
||||||
}
|
}
|
||||||
@@ -139,8 +139,8 @@ pub struct WrapPage {
|
|||||||
pub raw: usize,
|
pub raw: usize,
|
||||||
/// Wraps that reached us under a held plane but that no held key could open.
|
/// Wraps that reached us under a held plane but that no held key could open.
|
||||||
pub unreadable: usize,
|
pub unreadable: usize,
|
||||||
pub newest_ms: Option<u64>,
|
pub newest: Option<Timestamp>,
|
||||||
pub oldest_ms: Option<u64>,
|
pub oldest: Option<Timestamp>,
|
||||||
pub exhausted: bool,
|
pub exhausted: bool,
|
||||||
pub failed: bool,
|
pub failed: bool,
|
||||||
pub errors: usize,
|
pub errors: usize,
|
||||||
@@ -235,7 +235,7 @@ fn read_under(
|
|||||||
) -> Result<(OpenedStream, ChatRumor)> {
|
) -> Result<(OpenedStream, ChatRumor)> {
|
||||||
if held
|
if held
|
||||||
.retired_at
|
.retired_at
|
||||||
.is_some_and(|retired| wrap.created_at.as_secs() > retired)
|
.is_some_and(|retired| wrap.created_at > retired)
|
||||||
{
|
{
|
||||||
bail!("sealed after the key that reads it was retired");
|
bail!("sealed after the key that reads it was retired");
|
||||||
}
|
}
|
||||||
@@ -250,12 +250,12 @@ fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
|
|||||||
.authors(authors.iter().copied())
|
.authors(authors.iter().copied())
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
if let Some(until_ms) = window.until_ms {
|
if let Some(until) = window.until {
|
||||||
filter = filter.until(Timestamp::from_secs(until_ms / 1000));
|
filter = filter.until(until);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(since_ms) = window.since_ms {
|
if let Some(since) = window.since {
|
||||||
filter = filter.since(Timestamp::from_secs(since_ms / 1000));
|
filter = filter.since(since);
|
||||||
}
|
}
|
||||||
|
|
||||||
filter
|
filter
|
||||||
@@ -373,12 +373,12 @@ where
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct Walk {
|
struct Walk {
|
||||||
relays: Vec<Walker>,
|
relays: Vec<Walker>,
|
||||||
since_ms: Option<u64>,
|
since: Option<Timestamp>,
|
||||||
/// The inclusive upper bound of the next page.
|
/// The inclusive upper bound of the next page.
|
||||||
cursor: Option<u64>,
|
cursor: Option<Timestamp>,
|
||||||
seen: BTreeSet<EventId>,
|
seen: BTreeSet<EventId>,
|
||||||
newest_ms: Option<u64>,
|
newest: Option<Timestamp>,
|
||||||
oldest_ms: Option<u64>,
|
oldest: Option<Timestamp>,
|
||||||
raw: usize,
|
raw: usize,
|
||||||
errors: usize,
|
errors: usize,
|
||||||
/// Wraps the caller could not read under any held key.
|
/// Wraps the caller could not read under any held key.
|
||||||
@@ -402,11 +402,11 @@ impl Walk {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.map(|url| Walker { url, dead: false })
|
.map(|url| Walker { url, dead: false })
|
||||||
.collect(),
|
.collect(),
|
||||||
since_ms: window.since_ms,
|
since: window.since,
|
||||||
cursor: window.until_ms,
|
cursor: window.until,
|
||||||
seen: BTreeSet::new(),
|
seen: BTreeSet::new(),
|
||||||
newest_ms: None,
|
newest: None,
|
||||||
oldest_ms: None,
|
oldest: None,
|
||||||
raw: 0,
|
raw: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
unreadable: 0,
|
unreadable: 0,
|
||||||
@@ -421,8 +421,8 @@ impl Walk {
|
|||||||
/// The region the next page asks for.
|
/// The region the next page asks for.
|
||||||
fn region(&self) -> Window {
|
fn region(&self) -> Window {
|
||||||
Window {
|
Window {
|
||||||
until_ms: self.cursor,
|
until: self.cursor,
|
||||||
since_ms: self.since_ms,
|
since: self.since,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,13 +444,13 @@ impl Walk {
|
|||||||
self.bottom = true;
|
self.bottom = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut oldest: Option<u64> = None;
|
let mut oldest: Option<Timestamp> = None;
|
||||||
let mut events = Vec::with_capacity(page.len());
|
let mut events = Vec::with_capacity(page.len());
|
||||||
|
|
||||||
for event in page {
|
for event in page {
|
||||||
let at_ms = event.created_at.as_secs().saturating_mul(1000);
|
let at = event.created_at;
|
||||||
self.newest_ms = Some(self.newest_ms.map_or(at_ms, |newest| newest.max(at_ms)));
|
self.newest = Some(self.newest.map_or(at, |newest| newest.max(at)));
|
||||||
oldest = Some(oldest.map_or(at_ms, |oldest| oldest.min(at_ms)));
|
oldest = Some(oldest.map_or(at, |oldest| oldest.min(at)));
|
||||||
|
|
||||||
if self.seen.insert(event.id) {
|
if self.seen.insert(event.id) {
|
||||||
self.raw += 1;
|
self.raw += 1;
|
||||||
@@ -459,7 +459,7 @@ impl Walk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match oldest {
|
match oldest {
|
||||||
Some(oldest) if oldest > 0 => self.cursor = Some(oldest - 1),
|
Some(oldest) if !oldest.is_zero() => self.cursor = Some(oldest - 1u64),
|
||||||
Some(_) => self.bottom = true,
|
Some(_) => self.bottom = true,
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
@@ -474,8 +474,8 @@ impl Walk {
|
|||||||
opened,
|
opened,
|
||||||
raw: self.raw,
|
raw: self.raw,
|
||||||
unreadable: self.unreadable,
|
unreadable: self.unreadable,
|
||||||
newest_ms: self.newest_ms,
|
newest: self.newest,
|
||||||
oldest_ms: self.oldest_ms,
|
oldest: self.oldest,
|
||||||
exhausted: swept && self.raw > 0,
|
exhausted: swept && self.raw > 0,
|
||||||
failed: self.errors > 0 || (self.bottom && self.raw == 0),
|
failed: self.errors > 0 || (self.bottom && self.raw == 0),
|
||||||
errors: self.errors,
|
errors: self.errors,
|
||||||
@@ -500,9 +500,8 @@ mod tests {
|
|||||||
let mut events: Vec<Event> = database
|
let mut events: Vec<Event> = database
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|event| {
|
.filter(|event| {
|
||||||
let at_ms = event.created_at.as_secs().saturating_mul(1000);
|
window.until.is_none_or(|until| event.created_at <= until)
|
||||||
window.until_ms.is_none_or(|until| at_ms <= until)
|
&& window.since.is_none_or(|since| event.created_at >= since)
|
||||||
&& window.since_ms.is_none_or(|since| at_ms >= since)
|
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
@@ -613,7 +612,7 @@ mod tests {
|
|||||||
let held = HeldKey {
|
let held = HeldKey {
|
||||||
epoch: Epoch(0),
|
epoch: Epoch(0),
|
||||||
key: SECRET,
|
key: SECRET,
|
||||||
retired_at: Some(1_000),
|
retired_at: Some(Timestamp::from_secs(1_000)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let wrap_at = |channel: &ChannelId, at_ms: u64| {
|
let wrap_at = |channel: &ChannelId, at_ms: u64| {
|
||||||
@@ -656,7 +655,7 @@ mod tests {
|
|||||||
assert!(page.failed);
|
assert!(page.failed);
|
||||||
assert!(!page.exhausted);
|
assert!(!page.exhausted);
|
||||||
assert_eq!(page.raw, 0);
|
assert_eq!(page.raw, 0);
|
||||||
assert_eq!(page.oldest_ms, None);
|
assert_eq!(page.oldest, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -681,7 +680,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_page_boundary_is_exclusive() {
|
fn a_page_boundary_is_exclusive() {
|
||||||
let database = BTreeSet::from([event_at(1_700_000_000_000), event_at(1_700_000_001_000)]);
|
let database = BTreeSet::from([
|
||||||
|
event_at(Timestamp::from_secs(1_700_000_000)),
|
||||||
|
event_at(Timestamp::from_secs(1_700_000_001)),
|
||||||
|
]);
|
||||||
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||||
|
|
||||||
let first = walk.accept(serve_page(&database, walk.region(), 1), 1);
|
let first = walk.accept(serve_page(&database, walk.region(), 1), 1);
|
||||||
@@ -693,16 +695,16 @@ mod tests {
|
|||||||
|
|
||||||
let oldest = second
|
let oldest = second
|
||||||
.iter()
|
.iter()
|
||||||
.map(|event| event.created_at.as_secs() * 1000)
|
.map(|event| event.created_at)
|
||||||
.min()
|
.min()
|
||||||
.expect("one wrap");
|
.expect("one wrap");
|
||||||
assert_eq!(oldest, 1_700_000_000_000);
|
assert_eq!(oldest, Timestamp::from_secs(1_700_000_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn event_at(at_ms: u64) -> Event {
|
fn event_at(at: Timestamp) -> Event {
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
EventBuilder::new(Kind::TextNote, "page")
|
EventBuilder::new(Kind::TextNote, "page")
|
||||||
.custom_created_at(Timestamp::from_secs(at_ms / 1000))
|
.custom_created_at(at)
|
||||||
.finalize(&keys)
|
.finalize(&keys)
|
||||||
.expect("signs")
|
.expect("signs")
|
||||||
}
|
}
|
||||||
@@ -714,23 +716,23 @@ mod tests {
|
|||||||
assert_eq!(Window::opening(ChannelCursor::default()), Window::default());
|
assert_eq!(Window::opening(ChannelCursor::default()), Window::default());
|
||||||
|
|
||||||
let warm = Window::opening(ChannelCursor {
|
let warm = Window::opening(ChannelCursor {
|
||||||
newest_ms: Some(2_000_000),
|
newest: Some(Timestamp::from_secs(2_000_000)),
|
||||||
oldest_ms: Some(1_000),
|
oldest: Some(Timestamp::from_secs(1_000)),
|
||||||
exhausted: false,
|
exhausted: false,
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
warm,
|
warm,
|
||||||
Window {
|
Window {
|
||||||
since_ms: Some(2_000_000 - CURSOR_OVERLAP_MS),
|
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
|
||||||
until_ms: None,
|
until: None,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Window::older_than(1_000),
|
Window::older_than(Timestamp::from_secs(1_000)),
|
||||||
Window {
|
Window {
|
||||||
since_ms: None,
|
since: None,
|
||||||
until_ms: Some(999),
|
until: Some(Timestamp::from_secs(999)),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+297
-17
@@ -1,15 +1,21 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Result, bail};
|
||||||
|
use concord::cord01::{self, KIND_WRAP_EPHEMERAL, SealForm};
|
||||||
use concord::cord04::roles::{CommunityRoles, Permissions};
|
use concord::cord04::roles::{CommunityRoles, Permissions};
|
||||||
use concord::cord06::{self, Continuity, RekeyScope, Rotation, RotationKey};
|
use concord::cord04::{self, AuthorityCitation};
|
||||||
use concord::derive::{GroupKey, channel_rekey_group_key};
|
use concord::cord06::{
|
||||||
|
self, Continuity, Refounding, RekeyScope, Rotation, RotationKey, RotationPlan,
|
||||||
|
};
|
||||||
|
use concord::derive::{GroupKey, channel_rekey_group_key, epoch_key_commitment};
|
||||||
use concord::state::{CommunityState, HeldKey};
|
use concord::state::{CommunityState, HeldKey};
|
||||||
use concord::{ChannelId, CommunityId, Epoch, cord01};
|
use concord::{ChannelId, CommunityId, Epoch};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::UniversalSigner;
|
use state::UniversalSigner;
|
||||||
|
|
||||||
|
use crate::sync::{self, PlaneKind};
|
||||||
|
|
||||||
/// Epochs ahead of a held epoch a rotation is looked for.
|
/// Epochs ahead of a held epoch a rotation is looked for.
|
||||||
pub const REKEY_LOOKAHEAD: u64 = 8;
|
pub const REKEY_LOOKAHEAD: u64 = 8;
|
||||||
/// How much of what a relay stores a rekey watch replays.
|
/// How much of what a relay stores a rekey watch replays.
|
||||||
@@ -24,6 +30,15 @@ pub struct Watch {
|
|||||||
pub epoch: Epoch,
|
pub epoch: Epoch,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The permission a rotation of `scope` is judged under, by whoever reads it and
|
||||||
|
/// by this client when it publishes one.
|
||||||
|
fn permissions(scope: RekeyScope) -> &'static [u64] {
|
||||||
|
match scope {
|
||||||
|
RekeyScope::Base => &[Permissions::BAN],
|
||||||
|
RekeyScope::Channel(_) => &[Permissions::MANAGE_CHANNELS, Permissions::BAN],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Every address a community's rotations can arrive at.
|
/// Every address a community's rotations can arrive at.
|
||||||
pub fn watches(state: &CommunityState) -> Result<Vec<Watch>> {
|
pub fn watches(state: &CommunityState) -> Result<Vec<Watch>> {
|
||||||
let mut watches = Vec::new();
|
let mut watches = Vec::new();
|
||||||
@@ -135,6 +150,8 @@ pub struct BaseAdoption {
|
|||||||
pub epoch: Epoch,
|
pub epoch: Epoch,
|
||||||
pub key: [u8; 32],
|
pub key: [u8; 32],
|
||||||
pub control_pk: Option<PublicKey>,
|
pub control_pk: Option<PublicKey>,
|
||||||
|
/// The new Control Plane signing root, delivered to staff only.
|
||||||
|
pub control_root: Option<[u8; 32]>,
|
||||||
pub stepped: Vec<HeldKey>,
|
pub stepped: Vec<HeldKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +226,7 @@ pub async fn adopt(
|
|||||||
|
|
||||||
let base = walk(
|
let base = walk(
|
||||||
RekeyScope::Base,
|
RekeyScope::Base,
|
||||||
&[Permissions::BAN],
|
permissions(RekeyScope::Base),
|
||||||
state.root_epoch,
|
state.root_epoch,
|
||||||
state.community_root,
|
state.community_root,
|
||||||
state,
|
state,
|
||||||
@@ -226,9 +243,11 @@ pub async fn adopt(
|
|||||||
epoch: step.epoch,
|
epoch: step.epoch,
|
||||||
key: step.key,
|
key: step.key,
|
||||||
control_pk: step.control_pk,
|
control_pk: step.control_pk,
|
||||||
|
control_root: step.control_root,
|
||||||
stepped: step.stepped,
|
stepped: step.stepped,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
adoptions.removed_at = base.removed_at;
|
adoptions.removed_at = base.removed_at;
|
||||||
adoptions.stranded = base.stranded;
|
adoptions.stranded = base.stranded;
|
||||||
|
|
||||||
@@ -243,7 +262,7 @@ pub async fn adopt(
|
|||||||
|
|
||||||
let step = walk(
|
let step = walk(
|
||||||
RekeyScope::Channel(channel.id),
|
RekeyScope::Channel(channel.id),
|
||||||
&[Permissions::MANAGE_CHANNELS, Permissions::BAN],
|
permissions(RekeyScope::Channel(channel.id)),
|
||||||
held_epoch,
|
held_epoch,
|
||||||
held_key,
|
held_key,
|
||||||
state,
|
state,
|
||||||
@@ -272,6 +291,242 @@ pub async fn adopt(
|
|||||||
Ok(adoptions)
|
Ok(adoptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A rotation this client is asked to perform.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Rewrite {
|
||||||
|
pub scope: RekeyScope,
|
||||||
|
/// Every member the new key must reach.
|
||||||
|
pub recipients: Vec<PublicKey>,
|
||||||
|
/// The members it must not reach: whoever the rotation cuts off.
|
||||||
|
pub excluded: Vec<PublicKey>,
|
||||||
|
/// The rotator's claim to rank, when it holds the grant to cite.
|
||||||
|
pub citation: Option<AuthorityCitation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rewrite {
|
||||||
|
/// Whether `rotator` may cut `excluded` off at all.
|
||||||
|
pub fn authorized(
|
||||||
|
&self,
|
||||||
|
roles: &CommunityRoles,
|
||||||
|
owner: &PublicKey,
|
||||||
|
rotator: &PublicKey,
|
||||||
|
) -> bool {
|
||||||
|
permissions(self.scope)
|
||||||
|
.iter()
|
||||||
|
.any(|bits| cord06::rekey_authorized(roles, owner, rotator, *bits, &self.excluded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish this client's own rotation of one scope to its next epoch.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn rotate(
|
||||||
|
client: &Client,
|
||||||
|
state: &CommunityState,
|
||||||
|
roles: &CommunityRoles,
|
||||||
|
signer: &UniversalSigner,
|
||||||
|
me: PublicKey,
|
||||||
|
rewrite: &Rewrite,
|
||||||
|
at: Timestamp,
|
||||||
|
) -> Result<Epoch> {
|
||||||
|
if rewrite.excluded.contains(&me) {
|
||||||
|
bail!("a rotation cannot cut off the member who publishes it");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rewrite.recipients.contains(&me) {
|
||||||
|
bail!("a rotation must deliver its own rotator");
|
||||||
|
}
|
||||||
|
|
||||||
|
if rewrite
|
||||||
|
.excluded
|
||||||
|
.iter()
|
||||||
|
.any(|target| rewrite.recipients.contains(target))
|
||||||
|
{
|
||||||
|
bail!("a rotation cannot both deliver to and cut off the same member");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (held_epoch, held_key) = stepping_off(state, rewrite.scope)?;
|
||||||
|
let epoch = Epoch(held_epoch.0 + 1);
|
||||||
|
|
||||||
|
if epoch.0 > cord06::MAX_REKEY_EPOCH {
|
||||||
|
bail!("epoch {} is past the rekey ceiling", epoch.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan = cord06::plan_rotation(rewrite.scope, epoch)?;
|
||||||
|
let new_key = plan.new_key();
|
||||||
|
let control_pk = match &plan {
|
||||||
|
RotationPlan::Base(refounding) => Some(refounding.signer(&state.id)?.pk().to_bytes()),
|
||||||
|
RotationPlan::Channel { .. } => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut blobs = Vec::with_capacity(rewrite.recipients.len());
|
||||||
|
|
||||||
|
for recipient in &rewrite.recipients {
|
||||||
|
// A refounded Control Plane's root reaches staff only.
|
||||||
|
let control_root = match &plan {
|
||||||
|
RotationPlan::Base(refounding) => roles
|
||||||
|
.is_staff(recipient, &state.owner)
|
||||||
|
.then_some(refounding.new_control_root),
|
||||||
|
RotationPlan::Channel { .. } => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
blobs.push(
|
||||||
|
cord06::build_blob(
|
||||||
|
signer,
|
||||||
|
recipient,
|
||||||
|
rewrite.scope,
|
||||||
|
epoch,
|
||||||
|
&new_key,
|
||||||
|
control_pk.as_ref(),
|
||||||
|
control_root.as_ref(),
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = cord06::rekey_group(rewrite.scope, &state.community_root, &state.id, epoch)?;
|
||||||
|
// A rotation that cuts somebody off is the severed kind.
|
||||||
|
let severed = !rewrite.excluded.is_empty();
|
||||||
|
|
||||||
|
let mut wraps = cord06::build_rekey_chunks(
|
||||||
|
signer,
|
||||||
|
&group,
|
||||||
|
rewrite.scope,
|
||||||
|
epoch,
|
||||||
|
held_epoch,
|
||||||
|
&epoch_key_commitment(held_epoch, &held_key),
|
||||||
|
&blobs,
|
||||||
|
rewrite.citation.as_ref(),
|
||||||
|
severed,
|
||||||
|
at.as_secs(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let RotationPlan::Base(refounding) = &plan {
|
||||||
|
let carried = carry_heads(client, state, refounding, at).await?;
|
||||||
|
|
||||||
|
if carried.is_empty() && !state.heads.is_empty() {
|
||||||
|
log::warn!("community: a refounding carried no settled head forward");
|
||||||
|
}
|
||||||
|
|
||||||
|
wraps.extend(carried);
|
||||||
|
}
|
||||||
|
|
||||||
|
sync::publish_wraps(client, &wraps, &state.relays).await;
|
||||||
|
|
||||||
|
log::debug!(
|
||||||
|
"community: rotated epoch {} to {} for {} member(s)",
|
||||||
|
held_epoch.0,
|
||||||
|
epoch.0,
|
||||||
|
blobs.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(epoch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The epoch and key a rotation steps off.
|
||||||
|
fn stepping_off(state: &CommunityState, scope: RekeyScope) -> Result<(Epoch, [u8; 32])> {
|
||||||
|
match scope {
|
||||||
|
RekeyScope::Base => Ok((state.root_epoch, state.community_root)),
|
||||||
|
RekeyScope::Channel(channel) => state
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.find(|held| held.id == channel)
|
||||||
|
.and_then(|held| held.current())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no current key is held for {}", channel.to_hex())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-seal the settled control heads under a refounding's new groups.
|
||||||
|
///
|
||||||
|
/// A rotation only mints keys. Unless the heads ride across with it, the new
|
||||||
|
/// epoch's Control Plane starts empty, and a member whose material never carried
|
||||||
|
/// the root we are stepping off folds no roles, metadata or banlist at all.
|
||||||
|
async fn carry_heads(
|
||||||
|
client: &Client,
|
||||||
|
state: &CommunityState,
|
||||||
|
refounding: &Refounding,
|
||||||
|
at: Timestamp,
|
||||||
|
) -> Result<Vec<Event>> {
|
||||||
|
if state.heads.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let planes = sync::planes(state)?;
|
||||||
|
let authors: BTreeSet<PublicKey> = planes
|
||||||
|
.iter()
|
||||||
|
.filter(|plane| matches!(plane.kind, PlaneKind::Control(_)))
|
||||||
|
.map(|plane| plane.address)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if authors.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let wraps = client
|
||||||
|
.database()
|
||||||
|
.query(
|
||||||
|
Filter::new()
|
||||||
|
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
||||||
|
.authors(authors),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut seals = Vec::new();
|
||||||
|
let mut seen: BTreeSet<EventId> = BTreeSet::new();
|
||||||
|
|
||||||
|
for wrap in &wraps {
|
||||||
|
let Some(plane) = planes.iter().find(|plane| {
|
||||||
|
matches!(plane.kind, PlaneKind::Control(_)) && plane.address == wrap.pubkey
|
||||||
|
}) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A retired root reads only what was sealed before its rotation.
|
||||||
|
if !plane.accepts(wrap) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(opened) =
|
||||||
|
cord01::open_wrap_at(wrap, &plane.address, plane.group.conversation(), true)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only a plaintext seal can be carried: `compact` re-signs nothing and
|
||||||
|
// re-wraps the edition the author already sealed.
|
||||||
|
if opened.seal_form != SealForm::Plaintext {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(edition) = cord04::parse_edition(&opened.rumor) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let settled = state.heads.iter().any(|head| {
|
||||||
|
head.entity == edition.entity
|
||||||
|
&& head.version == edition.version
|
||||||
|
&& head.self_hash == edition.self_hash
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same edition can be reachable twice once a head has been carried
|
||||||
|
// forward before: a head is published once per refounding.
|
||||||
|
if settled && seen.insert(opened.seal.id) {
|
||||||
|
seals.push(opened.seal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if seals.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(cord06::compact(
|
||||||
|
&seals,
|
||||||
|
&refounding.read(&state.id)?,
|
||||||
|
&refounding.signer(&state.id)?,
|
||||||
|
at.as_secs(),
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The key grouping a rotation's chunks, recomputed from a collected rotation.
|
/// The key grouping a rotation's chunks, recomputed from a collected rotation.
|
||||||
fn rotation_key(rotation: &Rotation) -> RotationKey {
|
fn rotation_key(rotation: &Rotation) -> RotationKey {
|
||||||
(
|
(
|
||||||
@@ -295,9 +550,19 @@ struct Adopted {
|
|||||||
epoch: Epoch,
|
epoch: Epoch,
|
||||||
key: [u8; 32],
|
key: [u8; 32],
|
||||||
control_pk: Option<PublicKey>,
|
control_pk: Option<PublicKey>,
|
||||||
|
control_root: Option<[u8; 32]>,
|
||||||
stepped: Vec<HeldKey>,
|
stepped: Vec<HeldKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What one rotation offered this client, and when it was published.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Delivery {
|
||||||
|
key: [u8; 32],
|
||||||
|
control_pk: Option<PublicKey>,
|
||||||
|
control_root: Option<[u8; 32]>,
|
||||||
|
at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Walk a scope's rotations forward, one epoch at a time, off the key held.
|
/// Walk a scope's rotations forward, one epoch at a time, off the key held.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn walk(
|
async fn walk(
|
||||||
@@ -340,7 +605,7 @@ async fn walk(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut delivery: Option<([u8; 32], Option<PublicKey>, u64)> = None;
|
let mut delivery: Option<Delivery> = None;
|
||||||
let mut addressed = false;
|
let mut addressed = false;
|
||||||
|
|
||||||
for rotation in candidates
|
for rotation in candidates
|
||||||
@@ -384,34 +649,43 @@ async fn walk(
|
|||||||
let control_pk = delivered
|
let control_pk = delivered
|
||||||
.control_pk
|
.control_pk
|
||||||
.and_then(|bytes| PublicKey::from_slice(&bytes).ok());
|
.and_then(|bytes| PublicKey::from_slice(&bytes).ok());
|
||||||
let raced = delivery.as_ref().map(|held| held.0);
|
let raced = delivery.map(|held| held.key);
|
||||||
|
|
||||||
// Racing rotations converge on the lowest new key.
|
// Racing rotations converge on the lowest new key.
|
||||||
if raced.is_none_or(|raced| delivered.new_key < raced) {
|
if raced.is_none_or(|raced| delivered.new_key < raced) {
|
||||||
delivery = Some((delivered.new_key, control_pk, at_ms));
|
delivery = Some(Delivery {
|
||||||
|
key: delivered.new_key,
|
||||||
|
control_pk,
|
||||||
|
control_root: delivered.control_root,
|
||||||
|
at_ms,
|
||||||
|
});
|
||||||
} else if let Some(held) = delivery.as_mut() {
|
} else if let Some(held) = delivery.as_mut() {
|
||||||
held.2 = held.2.min(at_ms);
|
held.at_ms = held.at_ms.min(at_ms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((key, control_pk, at_ms)) = delivery {
|
if let Some(delivered) = delivery {
|
||||||
stepped.insert(
|
stepped.insert(
|
||||||
0,
|
0,
|
||||||
HeldKey {
|
HeldKey {
|
||||||
epoch: held_epoch,
|
epoch: held_epoch,
|
||||||
key: held_key,
|
key: held_key,
|
||||||
retired_at: Some(at_ms / 1000),
|
// The cutoff is a second-granular read boundary, so the
|
||||||
|
// rotation's millisecond publish time narrows to the second
|
||||||
|
// it landed in.
|
||||||
|
retired_at: Some(Timestamp::from_secs(delivered.at_ms / 1000)),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
held_epoch = target;
|
held_epoch = target;
|
||||||
held_key = key;
|
held_key = delivered.key;
|
||||||
|
|
||||||
step.adopted = Some(Adopted {
|
step.adopted = Some(Adopted {
|
||||||
epoch: target,
|
epoch: target,
|
||||||
key,
|
key: delivered.key,
|
||||||
control_pk,
|
control_pk: delivered.control_pk,
|
||||||
|
control_root: delivered.control_root,
|
||||||
stepped: stepped.clone(),
|
stepped: stepped.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -618,7 +892,10 @@ mod tests {
|
|||||||
assert_eq!(base.stepped.len(), 1);
|
assert_eq!(base.stepped.len(), 1);
|
||||||
assert_eq!(base.stepped[0].epoch, Epoch(0));
|
assert_eq!(base.stepped[0].epoch, Epoch(0));
|
||||||
assert_eq!(base.stepped[0].key, ROOT);
|
assert_eq!(base.stepped[0].key, ROOT);
|
||||||
assert_eq!(base.stepped[0].retired_at, Some(AT_MS / 1000));
|
assert_eq!(
|
||||||
|
base.stepped[0].retired_at,
|
||||||
|
Some(Timestamp::from_secs(AT_MS / 1000))
|
||||||
|
);
|
||||||
assert!(adoptions.removed_at.is_none());
|
assert!(adoptions.removed_at.is_none());
|
||||||
assert!(!adoptions.stranded);
|
assert!(!adoptions.stranded);
|
||||||
});
|
});
|
||||||
@@ -722,7 +999,10 @@ mod tests {
|
|||||||
assert_eq!(adopted.stepped.len(), 1);
|
assert_eq!(adopted.stepped.len(), 1);
|
||||||
assert_eq!(adopted.stepped[0].epoch, Epoch(0));
|
assert_eq!(adopted.stepped[0].epoch, Epoch(0));
|
||||||
assert_eq!(adopted.stepped[0].key, CHANNEL_KEY);
|
assert_eq!(adopted.stepped[0].key, CHANNEL_KEY);
|
||||||
assert_eq!(adopted.stepped[0].retired_at, Some(AT_MS / 1000));
|
assert_eq!(
|
||||||
|
adopted.stepped[0].retired_at,
|
||||||
|
Some(Timestamp::from_secs(AT_MS / 1000))
|
||||||
|
);
|
||||||
assert!(adoptions.cuts.is_empty());
|
assert!(adoptions.cuts.is_empty());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use nostr_sdk::prelude::*;
|
|||||||
use state::UniversalSigner;
|
use state::UniversalSigner;
|
||||||
|
|
||||||
use crate::cache::{self, Observed};
|
use crate::cache::{self, Observed};
|
||||||
use crate::history::{CURSOR_OVERLAP_MS, Window};
|
use crate::history::{CURSOR_OVERLAP, Window};
|
||||||
|
|
||||||
/// How much of what a relay stores a cold subscription replays per relay.
|
/// How much of what a relay stores a cold subscription replays per relay.
|
||||||
const LIVE_REPLAY: usize = 500;
|
const LIVE_REPLAY: usize = 500;
|
||||||
@@ -35,15 +35,15 @@ 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.
|
/// When the rotation that retired this plane's key published.
|
||||||
pub retired_at: Option<u64>,
|
pub retired_at: Option<Timestamp>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plane {
|
impl Plane {
|
||||||
/// Whether a wrap sealed under this plane is still inside its key's life.
|
/// Whether a wrap sealed under this plane is still inside its key's life.
|
||||||
pub fn accepts(&self, wrap: &Event) -> bool {
|
pub fn accepts(&self, wrap: &Event) -> bool {
|
||||||
self.retired_at
|
self.retired_at
|
||||||
.is_none_or(|retired| wrap.created_at.as_secs() <= retired)
|
.is_none_or(|retired| wrap.created_at <= retired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
|
|||||||
control_pk: None,
|
control_pk: None,
|
||||||
retired_at: None,
|
retired_at: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let group = control_group_key(&root.key, &state.id, epoch)?;
|
let group = control_group_key(&root.key, &state.id, epoch)?;
|
||||||
planes.push(Plane {
|
planes.push(Plane {
|
||||||
kind: PlaneKind::Control(epoch),
|
kind: PlaneKind::Control(epoch),
|
||||||
@@ -124,12 +125,12 @@ pub fn plane_filter(planes: &[Plane]) -> Filter {
|
|||||||
pub fn live_filter(planes: &[Plane], window: Window) -> Filter {
|
pub fn live_filter(planes: &[Plane], window: Window) -> Filter {
|
||||||
let mut filter = plane_filter(planes);
|
let mut filter = plane_filter(planes);
|
||||||
|
|
||||||
if let Some(until_ms) = window.until_ms {
|
if let Some(until) = window.until {
|
||||||
filter = filter.until(Timestamp::from_secs(until_ms / 1000));
|
filter = filter.until(until);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(since_ms) = window.since_ms {
|
if let Some(since) = window.since {
|
||||||
filter = filter.since(Timestamp::from_secs(since_ms / 1000));
|
filter = filter.since(since);
|
||||||
}
|
}
|
||||||
|
|
||||||
filter.limit(LIVE_REPLAY)
|
filter.limit(LIVE_REPLAY)
|
||||||
@@ -140,13 +141,13 @@ pub fn live_window(state: &CommunityState) -> Window {
|
|||||||
let floor = state
|
let floor = state
|
||||||
.channels
|
.channels
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
|
.filter_map(|channel| state.cursors.get(&channel.id)?.newest)
|
||||||
.min();
|
.min();
|
||||||
|
|
||||||
match floor {
|
match floor {
|
||||||
Some(floor) => Window {
|
Some(floor) => Window {
|
||||||
since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
|
since: Some(floor - CURSOR_OVERLAP),
|
||||||
until_ms: None,
|
until: None,
|
||||||
},
|
},
|
||||||
None => Window::default(),
|
None => Window::default(),
|
||||||
}
|
}
|
||||||
@@ -213,7 +214,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort publication of the genesis wraps to the community's relays.
|
/// Best-effort publication of the genesis wraps to the community's relays.
|
||||||
async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
|
pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
|
||||||
connect_relays(client, relays).await;
|
connect_relays(client, relays).await;
|
||||||
|
|
||||||
for wrap in wraps {
|
for wrap in wraps {
|
||||||
@@ -428,7 +429,7 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
|
|||||||
Some(held) => {
|
Some(held) => {
|
||||||
held.name = channel.name;
|
held.name = channel.name;
|
||||||
|
|
||||||
if cut.is_some_and(|cut| channel.epoch.0 <= cut) {
|
if cut.is_some_and(|cut| channel.epoch <= cut) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,16 +768,16 @@ mod tests {
|
|||||||
state.cursors.insert(
|
state.cursors.insert(
|
||||||
channel,
|
channel,
|
||||||
concord::state::ChannelCursor {
|
concord::state::ChannelCursor {
|
||||||
newest_ms: Some(2_000_000),
|
newest: Some(Timestamp::from_secs(2_000_000)),
|
||||||
oldest_ms: Some(1_000),
|
oldest: Some(Timestamp::from_secs(1_000)),
|
||||||
exhausted: false,
|
exhausted: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
live_window(&state),
|
live_window(&state),
|
||||||
Window {
|
Window {
|
||||||
since_ms: Some(2_000_000 - CURSOR_OVERLAP_MS),
|
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
|
||||||
until_ms: None,
|
until: None,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1138,7 +1139,7 @@ mod tests {
|
|||||||
priors: vec![HeldKey {
|
priors: vec![HeldKey {
|
||||||
epoch: Epoch(0),
|
epoch: Epoch(0),
|
||||||
key: [0x07; 32],
|
key: [0x07; 32],
|
||||||
retired_at: Some(1_000),
|
retired_at: Some(Timestamp::from_secs(1_000)),
|
||||||
}],
|
}],
|
||||||
}];
|
}];
|
||||||
|
|
||||||
@@ -1148,7 +1149,7 @@ mod tests {
|
|||||||
.find(|plane| plane.kind == PlaneKind::Channel(channel, Epoch(0)))
|
.find(|plane| plane.kind == PlaneKind::Channel(channel, Epoch(0)))
|
||||||
.expect("the retired epoch keeps a plane");
|
.expect("the retired epoch keeps a plane");
|
||||||
|
|
||||||
assert_eq!(retired.retired_at, Some(1_000));
|
assert_eq!(retired.retired_at, Some(Timestamp::from_secs(1_000)));
|
||||||
assert!(retired.accepts(&event_at(1_000_000)));
|
assert!(retired.accepts(&event_at(1_000_000)));
|
||||||
assert!(!retired.accepts(&event_at(1_001_000)));
|
assert!(!retired.accepts(&event_at(1_001_000)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,3 @@ gpui.workspace = true
|
|||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
log.workspace = true
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ rand.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
|
|||||||
@@ -534,6 +534,43 @@ pub fn plan_refounding(epoch: Epoch) -> Result<Refounding> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The material one rotation delivers, by scope.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RotationPlan {
|
||||||
|
/// A refounding: a fresh root, with the root that reads and signs under it.
|
||||||
|
Base(Refounding),
|
||||||
|
/// A channel: a fresh channel key, and nothing beside it.
|
||||||
|
Channel { epoch: Epoch, new_key: [u8; 32] },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RotationPlan {
|
||||||
|
pub fn epoch(&self) -> Epoch {
|
||||||
|
match self {
|
||||||
|
Self::Base(refounding) => refounding.epoch,
|
||||||
|
Self::Channel { epoch, .. } => *epoch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The secret the rotation delivers, which becomes the scope's new read key.
|
||||||
|
pub fn new_key(&self) -> [u8; 32] {
|
||||||
|
match self {
|
||||||
|
Self::Base(refounding) => refounding.new_root,
|
||||||
|
Self::Channel { new_key, .. } => *new_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint what a rotation of `scope` delivers at `epoch`.
|
||||||
|
pub fn plan_rotation(scope: RekeyScope, epoch: Epoch) -> Result<RotationPlan> {
|
||||||
|
match scope {
|
||||||
|
RekeyScope::Base => Ok(RotationPlan::Base(plan_refounding(epoch)?)),
|
||||||
|
RekeyScope::Channel(_) => Ok(RotationPlan::Channel {
|
||||||
|
epoch,
|
||||||
|
new_key: random_32()?,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Carries the settled heads across a refounding.
|
/// Carries the settled heads across a refounding.
|
||||||
pub fn compact(
|
pub fn compact(
|
||||||
seals: &[Event],
|
seals: &[Event],
|
||||||
|
|||||||
+97
-22
@@ -22,8 +22,10 @@ pub const STATE_PREFIX: &str = "concord/";
|
|||||||
pub struct HeldKey {
|
pub struct HeldKey {
|
||||||
pub epoch: Epoch,
|
pub epoch: Epoch,
|
||||||
pub key: [u8; 32],
|
pub key: [u8; 32],
|
||||||
|
/// The publish time of the rotation that superseded this key: a wrap
|
||||||
|
/// sealed under it later than this does not read.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub retired_at: Option<u64>,
|
pub retired_at: Option<Timestamp>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A community root epoch the client still holds, retained for the same reason.
|
/// A community root epoch the client still holds, retained for the same reason.
|
||||||
@@ -34,8 +36,9 @@ pub struct HeldRoot {
|
|||||||
/// The epoch's Control Plane signer, when the rotation delivered one.
|
/// The epoch's Control Plane signer, when the rotation delivered one.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub control_pk: Option<PublicKey>,
|
pub control_pk: Option<PublicKey>,
|
||||||
|
/// The publish time of the rotation that superseded this root.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub retired_at: Option<u64>,
|
pub retired_at: Option<Timestamp>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -59,15 +62,15 @@ impl ChannelKeyRef {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How far a channel's history sync has reached, in epoch milliseconds.
|
/// How far a channel's history sync has reached, in wrap times.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ChannelCursor {
|
pub struct ChannelCursor {
|
||||||
/// The newest wrap ingested, so a live subscription knows where to resume.
|
/// The newest wrap ingested, so a live subscription knows where to resume.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub newest_ms: Option<u64>,
|
pub newest: Option<Timestamp>,
|
||||||
/// The oldest wrap paged back to, so the next round resumes below it.
|
/// The oldest wrap paged back to, so the next round resumes below it.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub oldest_ms: Option<u64>,
|
pub oldest: Option<Timestamp>,
|
||||||
/// History verifiably swept to the bottom.
|
/// History verifiably swept to the bottom.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub exhausted: bool,
|
pub exhausted: bool,
|
||||||
@@ -76,14 +79,14 @@ pub struct ChannelCursor {
|
|||||||
impl ChannelCursor {
|
impl ChannelCursor {
|
||||||
pub fn merge(self, round: Self) -> Self {
|
pub fn merge(self, round: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
newest_ms: later(self.newest_ms, round.newest_ms),
|
newest: later(self.newest, round.newest),
|
||||||
oldest_ms: earlier(self.oldest_ms, round.oldest_ms),
|
oldest: earlier(self.oldest, round.oldest),
|
||||||
exhausted: self.exhausted || round.exhausted,
|
exhausted: self.exhausted || round.exhausted,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn later(held: Option<u64>, round: Option<u64>) -> Option<u64> {
|
fn later(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
|
||||||
match (held, round) {
|
match (held, round) {
|
||||||
(Some(held), Some(round)) => Some(held.max(round)),
|
(Some(held), Some(round)) => Some(held.max(round)),
|
||||||
(held, None) => held,
|
(held, None) => held,
|
||||||
@@ -91,7 +94,7 @@ fn later(held: Option<u64>, round: Option<u64>) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn earlier(held: Option<u64>, round: Option<u64>) -> Option<u64> {
|
fn earlier(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
|
||||||
match (held, round) {
|
match (held, round) {
|
||||||
(Some(held), Some(round)) => Some(held.min(round)),
|
(Some(held), Some(round)) => Some(held.min(round)),
|
||||||
(held, None) => held,
|
(held, None) => held,
|
||||||
@@ -121,23 +124,27 @@ pub struct CommunityState {
|
|||||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||||
pub banned: BTreeSet<PublicKey>,
|
pub banned: BTreeSet<PublicKey>,
|
||||||
/// 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,
|
||||||
|
rename = "channel_cursors",
|
||||||
|
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.
|
/// Root epochs superseded by a rotation we adopted, newest first.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub held_roots: Vec<HeldRoot>,
|
pub held_roots: Vec<HeldRoot>,
|
||||||
/// The epoch a channel rotation removed us at.
|
/// The epoch a channel rotation removed us at.
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub channel_cuts: BTreeMap<ChannelId, u64>,
|
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
|
||||||
/// The base epoch we were excluded at.
|
/// The base epoch we were excluded at.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub removed_at: Option<Epoch>,
|
pub removed_at: Option<Epoch>,
|
||||||
/// A complete rotation ahead of our epoch predates our join and carries no
|
/// A complete rotation ahead of our epoch predates our join and carries no blob.
|
||||||
/// blob for us: a stale invite landed us on a superseded epoch.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stranded: bool,
|
pub stranded: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dissolved: bool,
|
pub dissolved: bool,
|
||||||
|
/// When this community joined the member's list, in milliseconds.
|
||||||
pub added_at_ms: u64,
|
pub added_at_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +333,7 @@ impl CommunityState {
|
|||||||
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
|
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
|
||||||
self.channel_cuts
|
self.channel_cuts
|
||||||
.get(channel)
|
.get(channel)
|
||||||
.is_some_and(|cut| epoch.0 <= *cut)
|
.is_some_and(|cut| epoch <= *cut)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn floors(&self) -> Floors {
|
pub fn floors(&self) -> Floors {
|
||||||
@@ -431,8 +438,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_cursor_merge_only_moves_forward_and_never_seals() {
|
fn a_cursor_merge_only_moves_forward_and_never_seals() {
|
||||||
let held = ChannelCursor {
|
let held = ChannelCursor {
|
||||||
newest_ms: Some(1_000),
|
newest: Some(Timestamp::from_secs(1_000)),
|
||||||
oldest_ms: Some(5_000),
|
oldest: Some(Timestamp::from_secs(5_000)),
|
||||||
exhausted: false,
|
exhausted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -440,15 +447,15 @@ mod tests {
|
|||||||
assert_eq!(held.merge(ChannelCursor::default()), held);
|
assert_eq!(held.merge(ChannelCursor::default()), held);
|
||||||
|
|
||||||
let merged = held.merge(ChannelCursor {
|
let merged = held.merge(ChannelCursor {
|
||||||
newest_ms: Some(2_000),
|
newest: Some(Timestamp::from_secs(2_000)),
|
||||||
oldest_ms: Some(3_000),
|
oldest: Some(Timestamp::from_secs(3_000)),
|
||||||
exhausted: true,
|
exhausted: true,
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
merged,
|
merged,
|
||||||
ChannelCursor {
|
ChannelCursor {
|
||||||
newest_ms: Some(2_000),
|
newest: Some(Timestamp::from_secs(2_000)),
|
||||||
oldest_ms: Some(3_000),
|
oldest: Some(Timestamp::from_secs(3_000)),
|
||||||
exhausted: true,
|
exhausted: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -456,14 +463,82 @@ mod tests {
|
|||||||
// A later round that learned less cannot walk either bound back.
|
// A later round that learned less cannot walk either bound back.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
merged.merge(ChannelCursor {
|
merged.merge(ChannelCursor {
|
||||||
newest_ms: Some(1_500),
|
newest: Some(Timestamp::from_secs(1_500)),
|
||||||
oldest_ms: Some(4_000),
|
oldest: Some(Timestamp::from_secs(4_000)),
|
||||||
exhausted: false,
|
exhausted: false,
|
||||||
}),
|
}),
|
||||||
merged
|
merged
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A cursor stored when the boundaries were milliseconds must not be read as
|
||||||
|
/// seconds. The key it was stored under is gone, so the document's counters
|
||||||
|
/// are ignored and the channel re-syncs rather than being sealed off by an
|
||||||
|
/// `exhausted` that outlived the bounds it was earned against.
|
||||||
|
#[test]
|
||||||
|
fn a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted() {
|
||||||
|
let channel = ChannelId::from_bytes([0x9c; 32]);
|
||||||
|
let cursor = ChannelCursor {
|
||||||
|
newest: Some(Timestamp::from_secs(1_700_000_000)),
|
||||||
|
exhausted: true,
|
||||||
|
..ChannelCursor::default()
|
||||||
|
};
|
||||||
|
let mut state = CommunityState {
|
||||||
|
id: CommunityId::from_bytes([0x42; 32]),
|
||||||
|
name: None,
|
||||||
|
owner: Keys::generate().public_key(),
|
||||||
|
owner_salt: [0x01; 32],
|
||||||
|
community_root: [0x02; 32],
|
||||||
|
root_epoch: Epoch(0),
|
||||||
|
control_root: None,
|
||||||
|
control_pks: BTreeMap::new(),
|
||||||
|
channels: Vec::new(),
|
||||||
|
relays: Vec::new(),
|
||||||
|
heads: Vec::new(),
|
||||||
|
banned: BTreeSet::new(),
|
||||||
|
cursors: BTreeMap::new(),
|
||||||
|
held_roots: Vec::new(),
|
||||||
|
channel_cuts: BTreeMap::new(),
|
||||||
|
removed_at: None,
|
||||||
|
stranded: false,
|
||||||
|
dissolved: false,
|
||||||
|
added_at_ms: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The document a version that stored milliseconds wrote: its own key,
|
||||||
|
// and boundaries padded by a thousand.
|
||||||
|
let mut stored = serde_json::Map::new();
|
||||||
|
stored.insert(
|
||||||
|
channel.to_hex(),
|
||||||
|
serde_json::json!({
|
||||||
|
"newest_ms": 1_700_000_000_000u64,
|
||||||
|
"oldest_ms": 1_699_999_000_000u64,
|
||||||
|
"exhausted": true
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut legacy = serde_json::to_value(&state).expect("serializes");
|
||||||
|
legacy
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("a document")
|
||||||
|
.insert("cursors".to_owned(), serde_json::Value::Object(stored));
|
||||||
|
|
||||||
|
let read: CommunityState = serde_json::from_value(legacy).expect("deserializes");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
read.cursors.is_empty(),
|
||||||
|
"a millisecond cursor is not a seconds cursor"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A typed cursor still round-trips under the key it is written with.
|
||||||
|
state.cursors.insert(channel, cursor);
|
||||||
|
|
||||||
|
let document = serde_json::to_value(&state).expect("serializes");
|
||||||
|
let read: CommunityState = serde_json::from_value(document).expect("deserializes");
|
||||||
|
|
||||||
|
assert_eq!(read.cursors.get(&channel), Some(&cursor));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
|
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
|
||||||
let owner = Keys::generate().public_key();
|
let owner = Keys::generate().public_key();
|
||||||
|
|||||||
@@ -35,9 +35,20 @@ subscriptions too, which is what a page REQ is (`nostr-sdk/src/relay/inner.rs`
|
|||||||
`RelayNotification::Authenticated` arm). Only `AuthenticationFailed` ends a relay's
|
`RelayNotification::Authenticated` arm). Only `AuthenticationFailed` ends a relay's
|
||||||
wait, and it is reported rather than rendered as an empty channel.
|
wait, and it is reported rather than rendered as an empty channel.
|
||||||
|
|
||||||
|
**Revision 3.** One more correction, from a review of what revision 2 landed:
|
||||||
|
|
||||||
|
4. **Time is a type, not a `u64` with the unit in its name.** The read path held
|
||||||
|
three different units in the same `u64`: `HeldKey`/`HeldRoot.retired_at` held
|
||||||
|
seconds (but were compared with a `Timestamp` through `.as_secs()`),
|
||||||
|
`ChannelCursor` held milliseconds that were only ever seconds multiplied by a
|
||||||
|
thousand, and `CommunityState.channel_cuts` held epochs. Every filter boundary
|
||||||
|
divided by a thousand on the way out. §10 types the time fields (`Timestamp`,
|
||||||
|
`Epoch`) and leaves a millisecond only where a millisecond is real.
|
||||||
|
|
||||||
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, phase 3 added
|
swapped the transport and put the pump in charge of settling pages, phase 3 added
|
||||||
the rekey watch and held epochs, phase 4 made an empty or unreadable room say so):
|
the rekey watch and held epochs, phase 4 made an empty or unreadable room say so,
|
||||||
|
phase 5 typed the times they all compare):
|
||||||
|
|
||||||
```
|
```
|
||||||
CommunityPanel::load community_ui/src/lib.rs:192
|
CommunityPanel::load community_ui/src/lib.rs:192
|
||||||
@@ -277,10 +288,10 @@ impl Window {
|
|||||||
/// Nothing held: ask wide, the round pages down from there.
|
/// Nothing held: ask wide, the round pages down from there.
|
||||||
/// Something held: ask only for what is new, plus an overlap for the seam.
|
/// Something held: ask only for what is new, plus an overlap for the seam.
|
||||||
pub fn opening(cursor: ChannelCursor) -> Self {
|
pub fn opening(cursor: ChannelCursor) -> Self {
|
||||||
match cursor.newest_ms {
|
match cursor.newest {
|
||||||
Some(newest_ms) => Window {
|
Some(newest) => Window {
|
||||||
since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
|
since: Some(newest - CURSOR_OVERLAP),
|
||||||
until_ms: None,
|
until: None,
|
||||||
},
|
},
|
||||||
None => Window::default(),
|
None => Window::default(),
|
||||||
}
|
}
|
||||||
@@ -296,13 +307,13 @@ pub fn live_window(state: &CommunityState) -> Window {
|
|||||||
let floor = state
|
let floor = state
|
||||||
.channels
|
.channels
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
|
.filter_map(|channel| state.cursors.get(&channel.id)?.newest)
|
||||||
.min();
|
.min();
|
||||||
|
|
||||||
match floor {
|
match floor {
|
||||||
Some(floor) => Window {
|
Some(floor) => Window {
|
||||||
since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
|
since: Some(floor - CURSOR_OVERLAP),
|
||||||
until_ms: None,
|
until: None,
|
||||||
},
|
},
|
||||||
None => Window::default(),
|
None => Window::default(),
|
||||||
}
|
}
|
||||||
@@ -352,7 +363,7 @@ relay.
|
|||||||
|
|
||||||
Rules carried over from phase 1, none of which are negotiable:
|
Rules carried over from phase 1, none of which are negotiable:
|
||||||
|
|
||||||
- **A page boundary is exclusive.** `until = oldest_seen_ms - 1`, so consecutive
|
- **A page boundary is exclusive.** `until = oldest_seen - 1`, so consecutive
|
||||||
pages never share an event and the walk terminates.
|
pages never share an event and the walk terminates.
|
||||||
- **`exhausted` is earned.** Only `raw > 0` plus a bottomed-out page on every
|
- **`exhausted` is earned.** Only `raw > 0` plus a bottomed-out page on every
|
||||||
relay that answered may set it. An all-empty page sets `failed`, so the next
|
relay that answered may set it. An all-empty page sets `failed`, so the next
|
||||||
@@ -366,8 +377,8 @@ Rules carried over from phase 1, none of which are negotiable:
|
|||||||
as refused, and it is surfaced.
|
as refused, and it is surfaced.
|
||||||
- **One cursor, one filter per page.** The database is not per relay, so per-relay
|
- **One cursor, one filter per page.** The database is not per relay, so per-relay
|
||||||
cursors do not exist; a relay is a source that fills the database.
|
cursors do not exist; a relay is a source that fills the database.
|
||||||
- **`newest_ms` advances only on a complete round** (`!newest.failed && bridge.exhausted`),
|
- **`newest` advances only on a complete round** (`!newest.failed && bridge.exhausted`),
|
||||||
`oldest_ms` only walks down on a complete page, and `exhausted` is cleared by a
|
`oldest` only walks down on a complete page, and `exhausted` is cleared by a
|
||||||
rekey.
|
rekey.
|
||||||
|
|
||||||
The cold/warm distinction is now the *window rule* rather than a special case:
|
The cold/warm distinction is now the *window rule* rather than a special case:
|
||||||
@@ -375,9 +386,9 @@ The cold/warm distinction is now the *window rule* rather than a special case:
|
|||||||
| State of the channel | Window |
|
| State of the channel | Window |
|
||||||
| -------------------- | ------ |
|
| -------------------- | ------ |
|
||||||
| no cursor, nothing cached (cold open) | `Window::opening` → `since = None`: subscribe for everything the relays have, then page down |
|
| no cursor, nothing cached (cold open) | `Window::opening` → `since = None`: subscribe for everything the relays have, then page down |
|
||||||
| cursor with `newest_ms` (warm open) | `Window::opening` → `since = newest - CURSOR_OVERLAP`: new data only |
|
| cursor with `newest` (warm open) | `Window::opening` → `since = newest - CURSOR_OVERLAP`: new data only |
|
||||||
| cursor with `oldest_ms` | `Window::older_than(oldest_ms)`: older data, on demand |
|
| cursor with `oldest` | `Window::older_than(oldest)`: older data, on demand |
|
||||||
| a hole between `saved.newest_ms` and the newest seen | `Window::between(..)`: the bridge |
|
| a hole between `saved.newest` and the newest seen | `Window::between(..)`: the bridge |
|
||||||
|
|
||||||
So an open does two things, and they follow the same rule: it makes sure the live
|
So an open does two things, and they follow the same rule: it makes sure the live
|
||||||
REQ is installed — wide when nothing is held, which is where "subscribe to get all
|
REQ is installed — wide when nothing is held, which is where "subscribe to get all
|
||||||
@@ -390,9 +401,9 @@ And the passes fall out of that rule:
|
|||||||
- **Newest pass** (`CatchUp`): one page at `Window::opening(saved)`. Cold, this is
|
- **Newest pass** (`CatchUp`): one page at `Window::opening(saved)`. Cold, this is
|
||||||
"get all data"; warm, this is "get only new data".
|
"get all data"; warm, this is "get only new data".
|
||||||
- **Bridge**: while the previous page was *full* and still above
|
- **Bridge**: while the previous page was *full* and still above
|
||||||
`saved.newest_ms`, keep walking down with `until = oldest - 1`, bounded by
|
`saved.newest`, keep walking down with `until = oldest - 1`, bounded by
|
||||||
`CATCH_UP_PAGES`. A short page ends it, which is what lets `newest_ms` advance.
|
`CATCH_UP_PAGES`. A short page ends it, which is what lets `newest` advance.
|
||||||
- **Older pass**: resume at `saved.oldest_ms.or(oldest_seen)` and walk down,
|
- **Older pass**: resume at `saved.oldest.or(oldest_seen)` and walk down,
|
||||||
bounded by `CATCH_UP_PAGES` on a catch-up and `LOAD_OLDER_PAGES` on a scroll-up.
|
bounded by `CATCH_UP_PAGES` on a catch-up and `LOAD_OLDER_PAGES` on a scroll-up.
|
||||||
|
|
||||||
Sizes stay the reference numbers: `PAGE_WRAPS = 50`, `CATCH_UP_PAGES = 20`,
|
Sizes stay the reference numbers: `PAGE_WRAPS = 50`, `CATCH_UP_PAGES = 20`,
|
||||||
@@ -452,7 +463,7 @@ the next ones:
|
|||||||
|
|
||||||
- `ChannelKeyRef` gains
|
- `ChannelKeyRef` gains
|
||||||
`#[serde(default, skip_serializing_if = "Vec::is_empty")] priors: Vec<HeldKey>`
|
`#[serde(default, skip_serializing_if = "Vec::is_empty")] priors: Vec<HeldKey>`
|
||||||
where `HeldKey { epoch: Epoch, key: [u8; 32], retired_at: Option<u64> }`, and
|
where `HeldKey { epoch: Epoch, key: [u8; 32], retired_at: Option<Timestamp> }`, and
|
||||||
`CommunityState` gains the same shape for roots (`HeldRoot { epoch, key,
|
`CommunityState` gains the same shape for roots (`HeldRoot { epoch, key,
|
||||||
`control_pk, retired_at }`). `channel_secret` returns every held epoch,
|
`control_pk, retired_at }`). `channel_secret` returns every held epoch,
|
||||||
`history::page` already takes `&[(Epoch, [u8; 32])]`, and `sync::refresh` stops
|
`history::page` already takes `&[(Epoch, [u8; 32])]`, and `sync::refresh` stops
|
||||||
@@ -521,9 +532,49 @@ Three deviations from the sketch above, all of them smaller than planned:
|
|||||||
adoptable, so a member who missed several rotations catches up in one database
|
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.
|
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: a rotation this
|
#### The writer (phase 6) — **landed**
|
||||||
client published itself is not adopted locally (no rekey writer exists yet, so the
|
|
||||||
watch only ever adopts another member's).
|
Phase 3 gave the client a receiver; phase 6 gives it a hand. `rekey::rotate`
|
||||||
|
builds the rotation from the key the client actually holds and publishes it, and
|
||||||
|
`Community::rotate` is the entry a moderation surface will call:
|
||||||
|
|
||||||
|
- **One plan, drawn in the protocol crate.** `cord06::plan_rotation(scope, epoch)`
|
||||||
|
mints what the rotation delivers — a `Refounding` (new root and control pair)
|
||||||
|
for a base scope, a fresh key for a channel — beside `plan_refounding`, which is
|
||||||
|
now the base arm of it. No caller ever holds a rotation secret before the
|
||||||
|
rotation exists.
|
||||||
|
- **Authority first, and the same authority the receiver applies.** `Rewrite::authorized`
|
||||||
|
runs `cord06::rekey_authorized` under `permissions(scope)` — the same list
|
||||||
|
`adopt` now walks with, shared instead of written twice — so the client cannot
|
||||||
|
publish a rotation it would not itself adopt. `Community::rotate` adds the three
|
||||||
|
states the role fold cannot see: a removal, a strand and a ban.
|
||||||
|
- **One blob per recipient, and the rotator is one of them.** A rotation that
|
||||||
|
delivered no blob to its own rotator would strand them, so the writer refuses
|
||||||
|
one. Staff get the new Control Plane root beside the key, everyone else gets the
|
||||||
|
key alone.
|
||||||
|
- **A refounding carries its heads.** `carry_heads` reads the control editions
|
||||||
|
back out of the store, picks the ones the settled floors name, and `compact`s
|
||||||
|
them onto the new epoch's groups. Without it the new Control Plane starts empty
|
||||||
|
and the next reader that does not hold the old root folds no roles, metadata or
|
||||||
|
banlist at all. (`compact` re-signs nothing, so this only works on the plaintext
|
||||||
|
seals the Control Plane already uses.)
|
||||||
|
- **Then it adopts itself, through the ordinary receiver.** The chunks are saved
|
||||||
|
into the local database as they are sent, so `Community::rotate` calls the same
|
||||||
|
`rekey::adopt` the watch calls instead of adopting by construction: one path for
|
||||||
|
every rotation, whoever wrote it. Publish failures are logged per relay and do
|
||||||
|
not change the held state, so a rotation that only reached some relays still
|
||||||
|
leaves this client consistent — which is the same asymmetry the reference has.
|
||||||
|
- **The receiver now keeps the signing root it is handed.** `BaseAdoption`
|
||||||
|
carries the delivered `control_root` through to `state.control_root`; before
|
||||||
|
this, a refounding's root was dropped at the receiver and a staff member went on
|
||||||
|
signing under the epoch they had left.
|
||||||
|
|
||||||
|
What is still not here: the trigger. Kicking a member, choosing a channel's
|
||||||
|
remaining audience, and rendering any of it is the moderation and
|
||||||
|
community-management surface §8 keeps out of scope, so `Community::rotate` is an
|
||||||
|
API with no caller in the app yet — and `Rewrite.recipients` is the caller's to
|
||||||
|
name, because a private channel's audience is in each member's own invite and not
|
||||||
|
in the local state.
|
||||||
|
|
||||||
### 7. Honest states (phase 4) — **landed**
|
### 7. Honest states (phase 4) — **landed**
|
||||||
|
|
||||||
@@ -659,6 +710,50 @@ NIP-44-open them all. The plan's per-channel work queue in the pump turned out t
|
|||||||
be unnecessary for that: the first fold after a live wrap already opens exactly
|
be unnecessary for that: the first fold after a live wrap already opens exactly
|
||||||
the uncached wraps.
|
the uncached wraps.
|
||||||
|
|
||||||
|
### 10. Time is a type: `Timestamp`, not padded milliseconds (phase 5) — **landed**
|
||||||
|
|
||||||
|
The read path compared times through a bare `u64` in three units, and each
|
||||||
|
boundary paid for the confusion:
|
||||||
|
|
||||||
|
| Value | Held | What it cost |
|
||||||
|
| ----- | ---- | ------------ |
|
||||||
|
| `HeldKey.retired_at`, `HeldRoot.retired_at`, `Plane.retired_at` | seconds | compared with `wrap.created_at.as_secs()`, written as `at_ms / 1000` |
|
||||||
|
| `ChannelCursor.newest_ms`/`oldest_ms`, `WrapPage`, `Window`, the `Walk` bounds | milliseconds, always `secs * 1000` | `* 1000` on every accepted page, `/ 1000` on every filter |
|
||||||
|
| `CommunityState.channel_cuts` | epochs | `Epoch` unpacked to `u64` and rewrapped at the boundary |
|
||||||
|
| `added_at_ms`, a rumor's `at_ms` | milliseconds, genuinely | — |
|
||||||
|
|
||||||
|
What landed:
|
||||||
|
|
||||||
|
- `HeldKey`/`HeldRoot.retired_at`, `ChannelCursor.{newest,oldest}`,
|
||||||
|
`WrapPage.{newest,oldest}`, `Plane.retired_at`, `Window.{until,since}` and the
|
||||||
|
`Walk` bounds are `Timestamp`s. A window bound and a wrap's `created_at` are now
|
||||||
|
the same type, so `read_under` and `Plane::accepts` compare them directly,
|
||||||
|
`wrap_filter`/`live_filter` hand them to `until`/`since` unchanged, and the
|
||||||
|
page's exclusive boundary is written `oldest - 1` instead of `oldest_ms - 1`
|
||||||
|
truncated back to seconds.
|
||||||
|
- `CURSOR_OVERLAP` is a `Duration` (`60s`), not `60_000`, so
|
||||||
|
`newest - CURSOR_OVERLAP` reads as what it is.
|
||||||
|
- `channel_cuts` is `BTreeMap<ChannelId, Epoch>`, which is what
|
||||||
|
`rekey::Adoptions::cuts` already carried.
|
||||||
|
- **The two millisecond fields stay milliseconds.** A rumor's time is genuinely
|
||||||
|
finer than a wrap's second-granular `created_at`: CORD-01 carries the sub-second
|
||||||
|
offset in an `ms` tag and `resolve_ms_strict` puts it back, so a reader's
|
||||||
|
`at_ms` and `timeline`'s `before_ms` keep it. The cord02 list document's
|
||||||
|
`added_at`/`removed_at` keep it too, and not merely for fidelity: `added_at` is
|
||||||
|
an ordering key judged against a tombstone's `removed_at` under a strict `>`,
|
||||||
|
so narrowing both to seconds could tie a rejoin with the leave that preceded it.
|
||||||
|
- **A stored cursor is retired, not reinterpreted.** A document written when the
|
||||||
|
boundaries were milliseconds would deserialize into a `Timestamp` far in the
|
||||||
|
future, and its `exhausted` would then wedge the older pass for good. The map's
|
||||||
|
serde key changed (`cursors` → `channel_cursors`), so such a record is dropped
|
||||||
|
and the next round rebuilds the cursor from the wraps it reads;
|
||||||
|
`a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted` pins
|
||||||
|
both halves of that. `retired_at` needed no such treatment, because it was
|
||||||
|
already written in seconds.
|
||||||
|
|
||||||
|
No behaviour changes with it: the walk paged the same regions before and after,
|
||||||
|
because every millisecond it held was a second multiplied by a thousand.
|
||||||
|
|
||||||
## Order of work
|
## Order of work
|
||||||
|
|
||||||
### Phase 2a — move the code (no behaviour change) — **landed**
|
### Phase 2a — move the code (no behaviour change) — **landed**
|
||||||
@@ -777,13 +872,42 @@ Five recorded deviations:
|
|||||||
|
|
||||||
Phase 4 leaves the client honest about what it can see and what it can write.
|
Phase 4 leaves the client honest about what it can see and what it can write.
|
||||||
What it does not do is make it see more: the rekey writer, and with it adopting a
|
What it does not do is make it see more: the rekey writer, and with it adopting a
|
||||||
rotation this client published itself, remains open (see §6).
|
rotation this client published itself, was still open there (§6, closed in
|
||||||
|
phase 6).
|
||||||
|
|
||||||
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
|
||||||
is what keeps a rekey from stranding history; phase 4 is what makes an empty or
|
is what keeps a rekey from stranding history; phase 4 is what makes an empty or
|
||||||
unreadable room tell the truth.
|
unreadable room tell the truth.
|
||||||
|
|
||||||
|
### Phase 5 — typed time — **landed**
|
||||||
|
|
||||||
|
§10. `HeldKey`/`HeldRoot.retired_at`, `ChannelCursor`, `WrapPage`, `Plane`,
|
||||||
|
`Window` and the `Walk` bounds are `Timestamp`s; `channel_cuts` is `Epoch`;
|
||||||
|
`CURSOR_OVERLAP` is a `Duration`; and the cursor map's serde key was retired so no
|
||||||
|
stored millisecond cursor is read as seconds. No behaviour change, no relay
|
||||||
|
contact, no new dependency — the units the read path already compared by hand are
|
||||||
|
now the types it compares.
|
||||||
|
|
||||||
|
Gate, all green: `cargo test -p concord -p community` (51 + 32),
|
||||||
|
`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 6 — the rekey writer — **landed**
|
||||||
|
|
||||||
|
§6's writer: `cord06::plan_rotation`, `rekey::{Rewrite, rotate}`, `carry_heads`,
|
||||||
|
and `Community::rotate`, which publishes a rotation and then adopts it through the
|
||||||
|
receiver phase 3 built. The receiver also keeps the signing root a refounding
|
||||||
|
hands it now, instead of dropping it.
|
||||||
|
|
||||||
|
Gate, all green: `cargo test -p concord -p community` (51 + 32),
|
||||||
|
`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`. No test was added for the writer itself:
|
||||||
|
the publish half needs a relay, and the same reason the reference's own rekey
|
||||||
|
paths are exercised by hand applies here — see `Tests` below.
|
||||||
|
|
||||||
## Phase 1 status (landed)
|
## Phase 1 status (landed)
|
||||||
|
|
||||||
What phase 1 delivered, and what phase 2 replaces:
|
What phase 1 delivered, and what phase 2 replaces:
|
||||||
@@ -832,6 +956,10 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
|||||||
`Window::opening(saved)` starts at `newest - CURSOR_OVERLAP`; `Window::older_than`
|
`Window::opening(saved)` starts at `newest - CURSOR_OVERLAP`; `Window::older_than`
|
||||||
never includes the boundary event; `sync::live_window` is wide cold and resumes
|
never includes the boundary event; `sync::live_window` is wide cold and resumes
|
||||||
at the oldest held cursor warm.
|
at the oldest held cursor warm.
|
||||||
|
- The cursor's unit — **landed in phase 5**: a merge only moves forward and never
|
||||||
|
earns `exhausted`; a document whose cursors were stored in the old millisecond
|
||||||
|
encoding reads as no cursors at all, while a typed one round-trips
|
||||||
|
(`concord/src/state.rs`).
|
||||||
- The subscription plan — **landed in 2b**: a private channel's plane appears when
|
- The subscription plan — **landed in 2b**: a private channel's plane appears when
|
||||||
the key is held and is absent when it is not; `plane_filter` asks for both wrap
|
the key is held and is absent when it is not; `plane_filter` asks for both wrap
|
||||||
kinds and addresses every readable plane. The page-REQ half (a warm open's REQ
|
kinds and addresses every readable plane. The page-REQ half (a warm open's REQ
|
||||||
@@ -845,8 +973,15 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
|||||||
rotator who outranks us, published after we joined, reads as a removal; 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
|
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.
|
(`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
|
- The rekey writer — **landed in phase 6, structurally only.** No test covers it:
|
||||||
receives nothing.
|
the half that could be tested without a relay (that `plan_rotation` mints two
|
||||||
|
unrelated keys, that `Rewrite::authorized` agrees with the receiver) would not
|
||||||
|
catch the failures that matter (a blob addressed to the wrong epoch, a chunk set
|
||||||
|
the receiver cannot collect, a refounding that carries no head), and the half
|
||||||
|
that would — publish, re-read, adopt — needs a real relay and a second account.
|
||||||
|
What stands in for it is that the writer cannot take a path the receiver does
|
||||||
|
not: same `permissions`, same `build_rekey_chunks`, and adoption goes through
|
||||||
|
`rekey::adopt` rather than beside it.
|
||||||
- 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.
|
||||||
- What cannot be read — **landed in phase 4**: a wrap sealed after the rotation
|
- What cannot be read — **landed in phase 4**: a wrap sealed after the rotation
|
||||||
@@ -885,6 +1020,9 @@ outstanding are the ones that need a GPUI harness or two live accounts.
|
|||||||
Unread badges, notifications, message threads, pins, typing indicators and
|
Unread badges, notifications, message threads, pins, typing indicators and
|
||||||
presence (21059 wraps are wired for routing here, not for those features), file
|
presence (21059 wraps are wired for routing here, not for those features), file
|
||||||
and media rendering, moderation actions, and the community-management surfaces.
|
and media rendering, moderation actions, and the community-management surfaces.
|
||||||
|
The rekey writer is the one exception, and only its mechanism: `Community::rotate`
|
||||||
|
exists to be called, but nothing in the app calls it. Kicking a member, choosing
|
||||||
|
the audience a rotation keeps, and showing any of it stay out.
|
||||||
`crates/chat`'s DM path shares none of this code and is not touched; a later
|
`crates/chat`'s DM path shares none of this code and is not touched; a later
|
||||||
change can lift the page walk/cursor into a shared module if DMs grow the same
|
change can lift the page walk/cursor into a shared module if DMs grow the same
|
||||||
paging.
|
paging.
|
||||||
|
|||||||
+48
-26
@@ -245,7 +245,7 @@ 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
|
A key's `retired_at` (a `Timestamp`, 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
|
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.
|
epoch is history and never a live plane an ejected holder can keep writing into.
|
||||||
|
|
||||||
@@ -271,21 +271,24 @@ the handshake completes, so the page waits for the resubscribed answer rather
|
|||||||
than writing off a relay that only wanted to authenticate.
|
than writing off a relay that only wanted to authenticate.
|
||||||
|
|
||||||
`history::page` walks newest-first across every held epoch, caches what it opens, and
|
`history::page` walks newest-first across every held epoch, caches what it opens, and
|
||||||
reports what it saw: `oldest_ms`/`newest_ms` feed the caller's `ChannelCursor`,
|
reports what it saw: `oldest`/`newest` feed the caller's `ChannelCursor`,
|
||||||
`exhausted` is earned only by a short page *after* history was seen, and an
|
`exhausted` is earned only by a short page *after* history was seen, and an
|
||||||
all-empty answer sets `failed` so a later round re-asks instead of sealing the
|
all-empty answer sets `failed` so a later round re-asks instead of sealing the
|
||||||
channel at "no more history". `unreadable` counts the wraps the page reached that
|
channel at "no more history". `unreadable` counts the wraps the page reached that
|
||||||
no held key could open — sealed past the cutoff their key's rotation set, or bound
|
no held key could open — sealed past the cutoff their key's rotation set, or bound
|
||||||
to another channel — because those are history the reader is missing, not history
|
to another channel — because those are history the reader is missing, not history
|
||||||
that is not there. Page down with `Window::older_than(seen.oldest_ms)`,
|
that is not there. Page down with `Window::older_than(seen.oldest)`,
|
||||||
open a channel with `Window::opening(cursor)` (wide cold, `newest_ms - 60s` warm),
|
open a channel with `Window::opening(cursor)` (wide cold, `newest - 60s` warm),
|
||||||
and read the region between two cursors with `Window::between(..)`. `query_rumors`
|
and read the region between two cursors with `Window::between(..)`. A `Window`
|
||||||
is the read path when the group keys are gone; pass `kinds` to budget rows apart
|
carries `Timestamp`s, so its bounds go straight into a NIP-01 filter with no unit
|
||||||
from the events that only decorate them. `cache::wrapper_index` reads the cached
|
conversion; only a reader's millisecond `at_ms` narrows to a second at the query.
|
||||||
rows back keyed by the wrap they came from, which is what lets `sync::fold` observe
|
`query_rumors` is the read path when the group keys are gone; pass `kinds` to
|
||||||
author and message times without re-opening a wrap it already cached, and
|
budget rows apart from the events that only decorate them. `cache::wrapper_index`
|
||||||
`cache::purge_expired(client, &channel, now)` runs at the top of every round — the
|
reads the cached rows back keyed by the wrap they came from, which is what lets
|
||||||
timer is cooperative, so the local store is the artifact that has to forget.
|
`sync::fold` observe author and message times without re-opening a wrap it already
|
||||||
|
cached, and `cache::purge_expired(client, &channel, now)` runs at the top of every
|
||||||
|
round — the timer is cooperative, so the local store is the artifact that has to
|
||||||
|
forget.
|
||||||
|
|
||||||
`sync::fold` counts the same thing over the whole store, per channel, in
|
`sync::fold` counts the same thing over the whole store, per channel, in
|
||||||
`Snapshot.unreadable`. `sync_round` sums a round's pages into
|
`Snapshot.unreadable`. `sync_round` sums a round's pages into
|
||||||
@@ -415,25 +418,25 @@ whether a link still stands, and `fits()` is the write gate.
|
|||||||
## Rekeys, refounding and dissolution
|
## Rekeys, refounding and dissolution
|
||||||
|
|
||||||
A rotation is authority plus delivery: `rekey_authorized(&control.roles, &owner, &me, permission, &removed)`
|
A rotation is authority plus delivery: `rekey_authorized(&control.roles, &owner, &me, permission, &removed)`
|
||||||
gates it, `plan_refounding(epoch)` mints the new pair, and `build_rekey_chunks`
|
gates it, `plan_rotation(scope, epoch)` mints what it delivers, and
|
||||||
seals one blob per remaining member:
|
`build_rekey_chunks` seals one blob per remaining member:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use concord::derive::epoch_key_commitment;
|
use concord::derive::epoch_key_commitment;
|
||||||
use concord::cord06::{self, RekeyScope};
|
use concord::cord06::{self, RekeyScope, RotationPlan};
|
||||||
|
|
||||||
let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base
|
let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base
|
||||||
let plan = cord06::plan_refounding(Epoch(epoch + 1))?;
|
let plan = cord06::plan_rotation(scope, Epoch(epoch + 1))?;
|
||||||
|
let new_key = plan.new_key();
|
||||||
|
|
||||||
// A base rotation delivers the new control-plane keys beside the root; a channel
|
// A base rotation delivers the new control-plane keys beside the root; a channel
|
||||||
// rotation delivers only that channel's fresh key.
|
// rotation delivers only that channel's fresh key.
|
||||||
let new_key = plan.new_root;
|
let (control_pk, control_root) = match &plan {
|
||||||
let (control_pk, control_root) = match scope {
|
RotationPlan::Base(refounding) => {
|
||||||
RekeyScope::Base => {
|
let pk = refounding.signer(&community_id)?.pk().to_bytes();
|
||||||
let pk = plan.signer(&community_id)?.pk().to_bytes();
|
(Some(pk), is_staff.then_some(&refounding.new_control_root))
|
||||||
(Some(pk), is_staff.then_some(&plan.new_control_root))
|
|
||||||
}
|
}
|
||||||
RekeyScope::Channel(_) => (None, None),
|
RotationPlan::Channel { .. } => (None, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut blobs = Vec::with_capacity(members.len());
|
let mut blobs = Vec::with_capacity(members.len());
|
||||||
@@ -441,18 +444,24 @@ let mut blobs = Vec::with_capacity(members.len());
|
|||||||
for member in &members {
|
for member in &members {
|
||||||
blobs.push(
|
blobs.push(
|
||||||
cord06::build_blob(
|
cord06::build_blob(
|
||||||
&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root,
|
&my_keys,
|
||||||
|
member,
|
||||||
|
scope,
|
||||||
|
plan.epoch(),
|
||||||
|
&new_key,
|
||||||
|
control_pk.as_ref(),
|
||||||
|
control_root,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
|
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch())?;
|
||||||
let wraps = cord06::build_rekey_chunks(
|
let wraps = cord06::build_rekey_chunks(
|
||||||
&my_keys,
|
&my_keys,
|
||||||
&rekey_group,
|
&rekey_group,
|
||||||
scope,
|
scope,
|
||||||
plan.epoch,
|
plan.epoch(),
|
||||||
Epoch(epoch),
|
Epoch(epoch),
|
||||||
&epoch_key_commitment(Epoch(epoch), &community_root),
|
&epoch_key_commitment(Epoch(epoch), &community_root),
|
||||||
&blobs,
|
&blobs,
|
||||||
@@ -476,6 +485,18 @@ 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
|
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.
|
strand when a complete rotation carries no blob for the member.
|
||||||
|
|
||||||
|
`community::rekey::rotate` is the sender, run against the held state: it resolves
|
||||||
|
the epoch and key the scope is stepping off, refuses a rotation that would skip or
|
||||||
|
cut off its own rotator, delivers one blob per recipient (`Rewrite.recipients`,
|
||||||
|
which the caller names because a private channel's audience is not in the local
|
||||||
|
state), marks the rotation severed when it excludes somebody, and — for a base
|
||||||
|
scope — carries the settled Control Plane heads onto the new epoch's groups with
|
||||||
|
`cord06::compact`. `Community::rotate` checks `Rewrite::authorized` first, the same
|
||||||
|
`rekey_authorized` under the same permissions `adopt` applies, publishes to the
|
||||||
|
community's relays, and then adopts what it published through `rekey::adopt`
|
||||||
|
rather than by construction: a rotation this client wrote and one it received take
|
||||||
|
one path.
|
||||||
|
|
||||||
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.
|
||||||
@@ -737,5 +758,6 @@ client.subscribe(filter).with_id(sub_id).await?;
|
|||||||
rendered as a notice **and** enforced at write time: `Community::send` refuses
|
rendered as a notice **and** enforced at write time: `Community::send` refuses
|
||||||
when `channel_secret` is `None` (a removal, a strand, a channel cut, or a key we
|
when `channel_secret` is `None` (a removal, a strand, a channel cut, or a key we
|
||||||
never held), because a wrap sealed under a superseded root would reach nobody who
|
never held), because a wrap sealed under a superseded root would reach nobody who
|
||||||
rotated. The residual gap: this client does not adopt a rotation it published
|
rotated. A rotation this client publishes itself (`Community::rotate`) is adopted
|
||||||
itself, because there is no rekey writer yet.
|
through the same `rekey::adopt` an arriving one goes through, so the held state
|
||||||
|
and the wire cannot diverge.
|
||||||
|
|||||||
Reference in New Issue
Block a user