Compare commits
4
Commits
706cc72c1f
...
33ee06f559
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33ee06f559 | ||
|
|
7ca6634d69 | ||
|
|
366b289bf4 | ||
|
|
08c1687d87 |
@@ -46,11 +46,11 @@ pub enum Intent {
|
|||||||
/// What one round saw, for the caller to report and act on.
|
/// What one round saw, for the caller to report and act on.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct Progress {
|
pub struct Progress {
|
||||||
pub fetched: usize,
|
/// Wraps the round reached that no key this client holds can open.
|
||||||
pub opened: usize,
|
|
||||||
pub unreadable: usize,
|
pub unreadable: usize,
|
||||||
pub exhausted: bool,
|
/// A relay refused or never answered a page.
|
||||||
pub failed: bool,
|
pub failed: bool,
|
||||||
|
/// How many relays failed the round.
|
||||||
pub errors: usize,
|
pub errors: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +446,15 @@ impl Community {
|
|||||||
*seen = (*seen).max(count);
|
*seen = (*seen).max(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn missing_authority(&self) -> bool {
|
||||||
|
self.state.refounders.is_empty() && self.state.roots().len() > 1
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn tick(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn tick(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.missing_authority() {
|
||||||
|
self.rekey(cx);
|
||||||
|
}
|
||||||
|
|
||||||
let Some(channel) = self.active_channel() else {
|
let Some(channel) = self.active_channel() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -754,6 +762,17 @@ impl Community {
|
|||||||
let mut held = Vec::with_capacity(base.stepped.len() + self.state.held_roots.len());
|
let mut held = Vec::with_capacity(base.stepped.len() + self.state.held_roots.len());
|
||||||
|
|
||||||
for key in base.stepped {
|
for key in base.stepped {
|
||||||
|
let known = self
|
||||||
|
.state
|
||||||
|
.held_roots
|
||||||
|
.iter()
|
||||||
|
.chain(held.iter())
|
||||||
|
.any(|root| root.epoch == key.epoch && root.key == key.key);
|
||||||
|
|
||||||
|
if known {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
held.push(HeldRoot {
|
held.push(HeldRoot {
|
||||||
epoch: key.epoch,
|
epoch: key.epoch,
|
||||||
key: key.key,
|
key: key.key,
|
||||||
@@ -762,7 +781,17 @@ impl Community {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
held.extend(self.state.held_roots.iter().copied());
|
for root in &self.state.held_roots {
|
||||||
|
if held
|
||||||
|
.iter()
|
||||||
|
.any(|kept| kept.epoch == root.epoch && kept.key == root.key)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
held.push(*root);
|
||||||
|
}
|
||||||
|
|
||||||
self.state.held_roots = held;
|
self.state.held_roots = held;
|
||||||
|
|
||||||
if let Some(control_pk) = base.control_pk {
|
if let Some(control_pk) = base.control_pk {
|
||||||
@@ -776,10 +805,6 @@ impl Community {
|
|||||||
self.state.removed_at = None;
|
self.state.removed_at = None;
|
||||||
self.state.stranded = false;
|
self.state.stranded = false;
|
||||||
|
|
||||||
// Who minted these epochs is only ever stated by the rotation itself,
|
|
||||||
// and a Guestbook snapshot is honored on that authority.
|
|
||||||
self.state.refounders.extend(base.refounders);
|
|
||||||
|
|
||||||
// Every channel's plane moved with the root.
|
// Every channel's plane moved with the root.
|
||||||
touched.extend(self.state.channels.iter().map(|channel| channel.id));
|
touched.extend(self.state.channels.iter().map(|channel| channel.id));
|
||||||
}
|
}
|
||||||
@@ -814,6 +839,13 @@ impl Community {
|
|||||||
self.state.cursors.remove(&channel);
|
self.state.cursors.remove(&channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rotation that delivered us no key still names the npub that minted the epoch.
|
||||||
|
let learned = adoptions
|
||||||
|
.refounders
|
||||||
|
.into_iter()
|
||||||
|
.filter(|refounder| self.state.refounders.insert(*refounder))
|
||||||
|
.count();
|
||||||
|
|
||||||
if let Some(epoch) = adoptions.removed_at {
|
if let Some(epoch) = adoptions.removed_at {
|
||||||
self.state.removed_at = Some(epoch);
|
self.state.removed_at = Some(epoch);
|
||||||
}
|
}
|
||||||
@@ -822,8 +854,6 @@ impl Community {
|
|||||||
self.state.stranded = true;
|
self.state.stranded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A rotation re-opens the region its planes now cover: an exhausted
|
|
||||||
// verdict earned under the old keys cannot be trusted under the new.
|
|
||||||
for channel in &touched {
|
for channel in &touched {
|
||||||
if let Some(cursor) = self.state.cursors.get_mut(channel) {
|
if let Some(cursor) = self.state.cursors.get_mut(channel) {
|
||||||
cursor.exhausted = false;
|
cursor.exhausted = false;
|
||||||
@@ -834,6 +864,10 @@ impl Community {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
cx.emit(CommunityEvent::Updated(self.state.id));
|
cx.emit(CommunityEvent::Updated(self.state.id));
|
||||||
|
|
||||||
|
if learned > 0 {
|
||||||
|
self.refresh(cx);
|
||||||
|
}
|
||||||
|
|
||||||
let Some(channel) = self
|
let Some(channel) = self
|
||||||
.active
|
.active
|
||||||
.or_else(|| self.state.channels.first().map(|channel| channel.id))
|
.or_else(|| self.state.channels.first().map(|channel| channel.id))
|
||||||
@@ -886,11 +920,12 @@ impl Community {
|
|||||||
self.members = snapshot.members;
|
self.members = snapshot.members;
|
||||||
self.load_images(cx);
|
self.load_images(cx);
|
||||||
|
|
||||||
|
// A fold reads every plane, so its count is the truth for every
|
||||||
|
// channel rather than a sample of the region one round read.
|
||||||
|
// Replacing it is what lets the count fall again once a key is
|
||||||
|
// adopted; a partial round only ever raises it.
|
||||||
let reported = !snapshot.unreadable.is_empty();
|
let reported = !snapshot.unreadable.is_empty();
|
||||||
|
self.unreadable = snapshot.unreadable;
|
||||||
for (channel, count) in snapshot.unreadable {
|
|
||||||
self.record_unreadable(channel, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
if reported {
|
if reported {
|
||||||
cx.emit(CommunityEvent::Unreadable(self.state.id));
|
cx.emit(CommunityEvent::Unreadable(self.state.id));
|
||||||
@@ -1113,10 +1148,7 @@ async fn sync_round(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn absorb(progress: &mut Progress, page: &WrapPage) {
|
fn absorb(progress: &mut Progress, page: &WrapPage) {
|
||||||
progress.fetched += page.raw;
|
|
||||||
progress.opened += page.opened.len();
|
|
||||||
progress.unreadable += page.unreadable;
|
progress.unreadable += page.unreadable;
|
||||||
progress.exhausted |= page.exhausted;
|
|
||||||
progress.failed |= page.failed;
|
progress.failed |= page.failed;
|
||||||
progress.errors += page.errors;
|
progress.errors += page.errors;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -708,22 +708,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_the_list_and_a_community_id_have_a_route() {
|
|
||||||
let id = CommunityId::from_bytes([0x42; 32]);
|
|
||||||
|
|
||||||
assert_eq!(route_of(&sync::list_subscription_id()), Some(Route::List));
|
|
||||||
assert_eq!(
|
|
||||||
route_of(&sync::subscription_id(&id)),
|
|
||||||
Some(Route::Community(id))
|
|
||||||
);
|
|
||||||
assert_eq!(route_of(&SubscriptionId::new("concord-history-3")), None);
|
|
||||||
assert_eq!(
|
|
||||||
route_of(&SubscriptionId::new("some/other/subscription")),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A burst within one window folds each community once, and the list once,
|
/// A burst within one window folds each community once, and the list once,
|
||||||
/// however many events arrived.
|
/// however many events arrived.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ pub struct Adoptions {
|
|||||||
/// The base epoch a complete rotation excluded us at.
|
/// The base epoch a complete rotation excluded us at.
|
||||||
pub removed_at: Option<Epoch>,
|
pub removed_at: Option<Epoch>,
|
||||||
pub stranded: bool,
|
pub stranded: bool,
|
||||||
|
/// The npubs whose rotations minted an epoch of this community.
|
||||||
|
pub refounders: BTreeSet<PublicKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Adoptions {
|
impl Adoptions {
|
||||||
@@ -149,6 +151,7 @@ impl Adoptions {
|
|||||||
&& self.cuts.is_empty()
|
&& self.cuts.is_empty()
|
||||||
&& self.removed_at.is_none()
|
&& self.removed_at.is_none()
|
||||||
&& !self.stranded
|
&& !self.stranded
|
||||||
|
&& self.refounders.is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +163,6 @@ pub struct BaseAdoption {
|
|||||||
/// The new Control Plane signing root, delivered to staff only.
|
/// The new Control Plane signing root, delivered to staff only.
|
||||||
pub control_root: Option<[u8; 32]>,
|
pub control_root: Option<[u8; 32]>,
|
||||||
pub stepped: Vec<HeldKey>,
|
pub stepped: Vec<HeldKey>,
|
||||||
/// The npubs whose rotations minted the epochs this walk passed through.
|
|
||||||
pub refounders: BTreeSet<PublicKey>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -247,11 +248,7 @@ pub async fn adopt(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut refounders: BTreeSet<PublicKey> = base
|
let mut refounders: BTreeSet<PublicKey> = base.refounders.clone();
|
||||||
.adopted
|
|
||||||
.as_ref()
|
|
||||||
.map(|adopted| adopted.refounders.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
for root in state.roots().into_iter().skip(1) {
|
for root in state.roots().into_iter().skip(1) {
|
||||||
let prior = walk(
|
let prior = walk(
|
||||||
@@ -268,12 +265,12 @@ pub async fn adopt(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
refounders.extend(prior.refounders.iter().copied());
|
||||||
|
|
||||||
let Some(adopted) = prior.adopted else {
|
let Some(adopted) = prior.adopted else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
refounders.extend(adopted.refounders.iter().copied());
|
|
||||||
|
|
||||||
if base
|
if base
|
||||||
.adopted
|
.adopted
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -290,10 +287,10 @@ pub async fn adopt(
|
|||||||
control_pk: step.control_pk,
|
control_pk: step.control_pk,
|
||||||
control_root: step.control_root,
|
control_root: step.control_root,
|
||||||
stepped: step.stepped,
|
stepped: step.stepped,
|
||||||
refounders,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
adoptions.refounders = refounders;
|
||||||
adoptions.removed_at = base.removed_at;
|
adoptions.removed_at = base.removed_at;
|
||||||
adoptions.stranded = base.stranded;
|
adoptions.stranded = base.stranded;
|
||||||
|
|
||||||
@@ -589,6 +586,9 @@ struct Step {
|
|||||||
adopted: Option<Adopted>,
|
adopted: Option<Adopted>,
|
||||||
removed_at: Option<Epoch>,
|
removed_at: Option<Epoch>,
|
||||||
stranded: bool,
|
stranded: bool,
|
||||||
|
/// The rotators of every base rotation this walk verified, which is who may
|
||||||
|
/// seed the Guestbook snapshot of an epoch they minted.
|
||||||
|
refounders: BTreeSet<PublicKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -598,9 +598,6 @@ struct Adopted {
|
|||||||
control_pk: Option<PublicKey>,
|
control_pk: Option<PublicKey>,
|
||||||
control_root: Option<[u8; 32]>,
|
control_root: Option<[u8; 32]>,
|
||||||
stepped: Vec<HeldKey>,
|
stepped: Vec<HeldKey>,
|
||||||
/// The rotator of every base epoch this walk passed through,
|
|
||||||
/// which is who may seed the Guestbook snapshot of the epoch it minted.
|
|
||||||
refounders: BTreeSet<PublicKey>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What one rotation offered this client, and when it was published.
|
/// What one rotation offered this client, and when it was published.
|
||||||
@@ -610,7 +607,6 @@ struct Delivery {
|
|||||||
control_pk: Option<PublicKey>,
|
control_pk: Option<PublicKey>,
|
||||||
control_root: Option<[u8; 32]>,
|
control_root: Option<[u8; 32]>,
|
||||||
at_ms: u64,
|
at_ms: u64,
|
||||||
rotator: PublicKey,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -629,7 +625,6 @@ async fn walk(
|
|||||||
) -> Result<Step> {
|
) -> Result<Step> {
|
||||||
let mut step = Step::default();
|
let mut step = Step::default();
|
||||||
let mut stepped: Vec<HeldKey> = Vec::new();
|
let mut stepped: Vec<HeldKey> = Vec::new();
|
||||||
let mut refounders: BTreeSet<PublicKey> = BTreeSet::new();
|
|
||||||
let ceiling = held_epoch.0 + REKEY_LOOKAHEAD;
|
let ceiling = held_epoch.0 + REKEY_LOOKAHEAD;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -663,6 +658,16 @@ async fn walk(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends)
|
.filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends)
|
||||||
{
|
{
|
||||||
|
// Who minted an epoch is proven by the rotation itself, not by a blob:
|
||||||
|
// a rotation is only a candidate after continuity against a key we
|
||||||
|
// hold, so its rotator minted this epoch whether or not it addressed
|
||||||
|
// us. A member who joined on a stale bundle never held the epochs
|
||||||
|
// between, and the snapshot that seeds them is only honored on this
|
||||||
|
// npub's authority (CORD-02 §5).
|
||||||
|
if scope == RekeyScope::Base {
|
||||||
|
step.refounders.insert(rotation.rotator);
|
||||||
|
}
|
||||||
|
|
||||||
let at_ms = published
|
let at_ms = published
|
||||||
.get(&rotation_key(rotation))
|
.get(&rotation_key(rotation))
|
||||||
.copied()
|
.copied()
|
||||||
@@ -709,7 +714,6 @@ async fn walk(
|
|||||||
control_pk,
|
control_pk,
|
||||||
control_root: delivered.control_root,
|
control_root: delivered.control_root,
|
||||||
at_ms,
|
at_ms,
|
||||||
rotator: rotation.rotator,
|
|
||||||
});
|
});
|
||||||
} else if let Some(held) = delivery.as_mut() {
|
} else if let Some(held) = delivery.as_mut() {
|
||||||
held.at_ms = held.at_ms.min(at_ms);
|
held.at_ms = held.at_ms.min(at_ms);
|
||||||
@@ -730,17 +734,12 @@ async fn walk(
|
|||||||
held_epoch = target;
|
held_epoch = target;
|
||||||
held_key = delivered.key;
|
held_key = delivered.key;
|
||||||
|
|
||||||
if scope == RekeyScope::Base {
|
|
||||||
refounders.insert(delivered.rotator);
|
|
||||||
}
|
|
||||||
|
|
||||||
step.adopted = Some(Adopted {
|
step.adopted = Some(Adopted {
|
||||||
epoch: target,
|
epoch: target,
|
||||||
key: delivered.key,
|
key: delivered.key,
|
||||||
control_pk: delivered.control_pk,
|
control_pk: delivered.control_pk,
|
||||||
control_root: delivered.control_root,
|
control_root: delivered.control_root,
|
||||||
stepped: stepped.clone(),
|
stepped: stepped.clone(),
|
||||||
refounders: refounders.clone(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -1022,7 +1021,76 @@ mod tests {
|
|||||||
assert_eq!(base.key, NEWER_ROOT);
|
assert_eq!(base.key, NEWER_ROOT);
|
||||||
assert_eq!(base.stepped.len(), 1, "the root it stepped off");
|
assert_eq!(base.stepped.len(), 1, "the root it stepped off");
|
||||||
assert_eq!(base.stepped[0].epoch, Epoch(1));
|
assert_eq!(base.stepped[0].epoch, Epoch(1));
|
||||||
assert!(base.refounders.contains(&owner.public_key()));
|
assert!(adoptions.refounders.contains(&owner.public_key()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rotation that addresses somebody else still names the npub who minted the
|
||||||
|
/// epoch: continuity against a key we hold is what proves it. That authority is
|
||||||
|
/// what a Guestbook snapshot is honored on, so a member who joined on a stale
|
||||||
|
/// bundle can still read the roster the Refounding seeded.
|
||||||
|
#[test]
|
||||||
|
fn a_rotation_that_delivered_us_no_key_still_names_its_refounder() {
|
||||||
|
smol::block_on(async {
|
||||||
|
let client = client();
|
||||||
|
let owner = Keys::generate();
|
||||||
|
let me = Keys::generate();
|
||||||
|
let other = Keys::generate();
|
||||||
|
let id = CommunityId::from_bytes([0x42; 32]);
|
||||||
|
let channel = ChannelId::from_bytes([0x9c; 32]);
|
||||||
|
|
||||||
|
// At epoch 2, holding the root the rotation stepped off but never
|
||||||
|
// having walked it: the shape a stale join bundle produces.
|
||||||
|
let mut state = state(owner.public_key(), id, channel);
|
||||||
|
state.root_epoch = Epoch(2);
|
||||||
|
state.community_root = NEWER_ROOT;
|
||||||
|
state.held_roots = vec![HeldRoot {
|
||||||
|
epoch: Epoch(1),
|
||||||
|
key: NEW_ROOT,
|
||||||
|
control_pk: None,
|
||||||
|
retired_at: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let blob = smol::block_on(build_blob(
|
||||||
|
&owner,
|
||||||
|
&other.public_key(),
|
||||||
|
RekeyScope::Base,
|
||||||
|
Epoch(2),
|
||||||
|
&NEWER_ROOT,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
.expect("builds");
|
||||||
|
let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives");
|
||||||
|
let wraps = smol::block_on(build_rekey_chunks(
|
||||||
|
&owner,
|
||||||
|
&group,
|
||||||
|
RekeyScope::Base,
|
||||||
|
Epoch(2),
|
||||||
|
Epoch(1),
|
||||||
|
&epoch_key_commitment(Epoch(1), &NEW_ROOT),
|
||||||
|
&[blob],
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
AT_MS / 1000,
|
||||||
|
))
|
||||||
|
.expect("builds");
|
||||||
|
store(&client, &wraps).await;
|
||||||
|
|
||||||
|
let signer = UniversalSigner::new(me.clone());
|
||||||
|
let adoptions = adopt(
|
||||||
|
&client,
|
||||||
|
&state,
|
||||||
|
&CommunityRoles::default(),
|
||||||
|
&signer,
|
||||||
|
me.public_key(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("reads");
|
||||||
|
|
||||||
|
assert!(adoptions.base.is_none(), "nothing to adopt");
|
||||||
|
assert!(!adoptions.is_empty(), "the minter is still learned");
|
||||||
|
assert!(adoptions.refounders.contains(&owner.public_key()));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+141
-35
@@ -388,6 +388,8 @@ pub async fn load(
|
|||||||
};
|
};
|
||||||
|
|
||||||
retain_join_root(&mut state, &entry.seed);
|
retain_join_root(&mut state, &entry.seed);
|
||||||
|
adopt_list_material(&mut state, &entry.seed);
|
||||||
|
adopt_list_material(&mut state, &entry.current);
|
||||||
|
|
||||||
cache::save_state(client, &state).await?;
|
cache::save_state(client, &state).await?;
|
||||||
held.insert(entry.community_id, state);
|
held.insert(entry.community_id, state);
|
||||||
@@ -396,6 +398,27 @@ pub async fn load(
|
|||||||
Ok(held.into_values().collect())
|
Ok(held.into_values().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take the snapshot authority and retained roots a List entry names.
|
||||||
|
fn adopt_list_material(state: &mut CommunityState, material: &JoinMaterial) {
|
||||||
|
if material.root_epoch.0 > 0
|
||||||
|
&& let Some(refounder) = material.refounder()
|
||||||
|
{
|
||||||
|
state.refounders.insert(refounder);
|
||||||
|
}
|
||||||
|
|
||||||
|
for root in material.held_roots() {
|
||||||
|
if root.epoch >= state.root_epoch || root.epoch.0 == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(refounder) = root.refounder {
|
||||||
|
state.refounders.insert(refounder);
|
||||||
|
}
|
||||||
|
|
||||||
|
retain_root(state, root.epoch, root.key, root.control_pk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn retain_join_root(state: &mut CommunityState, seed: &JoinMaterial) {
|
fn retain_join_root(state: &mut CommunityState, seed: &JoinMaterial) {
|
||||||
if seed.root_epoch >= state.root_epoch {
|
if seed.root_epoch >= state.root_epoch {
|
||||||
return;
|
return;
|
||||||
@@ -605,6 +628,11 @@ pub async fn fold(client: &Client, state: &CommunityState) -> Result<Option<Snap
|
|||||||
|
|
||||||
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
|
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
|
||||||
{
|
{
|
||||||
|
observe(
|
||||||
|
&mut observed,
|
||||||
|
edition.author,
|
||||||
|
wrap.created_at.as_secs().saturating_mul(1000),
|
||||||
|
);
|
||||||
editions.push(edition);
|
editions.push(edition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -967,6 +995,50 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A List entry that names the npub whose Refounding minted its epoch hands
|
||||||
|
/// this client the authority its Guestbook snapshot is honored on, which is
|
||||||
|
/// how a device that never held the rotation still reads the seeded roster.
|
||||||
|
/// (What the fold then does with that authority is the neighboring test.)
|
||||||
|
#[test]
|
||||||
|
fn a_list_entrys_refounder_becomes_the_snapshot_authority() {
|
||||||
|
smol::block_on(async {
|
||||||
|
let client = client();
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let signer = UniversalSigner::new(keys.clone());
|
||||||
|
let refounder = Keys::generate();
|
||||||
|
|
||||||
|
let joined = held(
|
||||||
|
CommunityId::from_bytes([0x42; 32]),
|
||||||
|
Keys::generate().public_key(),
|
||||||
|
);
|
||||||
|
let mut rotated = joined.clone();
|
||||||
|
rotated.root_epoch = Epoch(2);
|
||||||
|
rotated.community_root = [0x44; 32];
|
||||||
|
|
||||||
|
let mut entry = list_entry(&rotated, "coop");
|
||||||
|
entry.seed = list_entry(&joined, "coop").seed;
|
||||||
|
entry.added_at = joined.added_at_ms;
|
||||||
|
entry.current.extra.insert(
|
||||||
|
"refounder".to_owned(),
|
||||||
|
serde_json::Value::String(refounder.public_key().to_hex()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let list = CommunityList::default().joined(entry);
|
||||||
|
store_fragment(&client, &signer, &list).await;
|
||||||
|
|
||||||
|
let loaded = load(&client, &signer, keys.public_key())
|
||||||
|
.await
|
||||||
|
.expect("loads");
|
||||||
|
let state = loaded
|
||||||
|
.iter()
|
||||||
|
.find(|state| state.id == joined.id)
|
||||||
|
.expect("loaded");
|
||||||
|
|
||||||
|
assert_eq!(state.root_epoch, Epoch(2));
|
||||||
|
assert!(state.refounders.contains(&refounder.public_key()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// A List that has rotated on keeps the root of our join: the material is
|
/// A List that has rotated on keeps the root of our join: the material is
|
||||||
/// the community as we were given it, and the planes that root addressed
|
/// the community as we were given it, and the planes that root addressed
|
||||||
/// stay readable only while it is held.
|
/// stay readable only while it is held.
|
||||||
@@ -1015,32 +1087,6 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The List discovering a root moves the community on without dropping the
|
|
||||||
/// one held: only the current root is exchanged for the material's.
|
|
||||||
#[test]
|
|
||||||
fn a_list_material_at_a_newer_epoch_keeps_the_root_it_superseded() {
|
|
||||||
let held_state = held(
|
|
||||||
CommunityId::from_bytes([0x42; 32]),
|
|
||||||
Keys::generate().public_key(),
|
|
||||||
);
|
|
||||||
let mut fresh = held_state.clone();
|
|
||||||
fresh.root_epoch = Epoch(3);
|
|
||||||
fresh.community_root = [0x55; 32];
|
|
||||||
|
|
||||||
let merged = refresh(held_state.clone(), fresh);
|
|
||||||
|
|
||||||
assert_eq!(merged.root_epoch, Epoch(3));
|
|
||||||
assert_eq!(merged.community_root, [0x55; 32]);
|
|
||||||
assert_eq!(
|
|
||||||
merged
|
|
||||||
.held_roots
|
|
||||||
.iter()
|
|
||||||
.map(|root| (root.epoch, root.key))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![(held_state.root_epoch, held_state.community_root)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What `CommunityRegistry` needs from a created community: a state document
|
/// What `CommunityRegistry` needs from a created community: a state document
|
||||||
/// `load` finds, a control plane the subscription filter actually addresses,
|
/// `load` finds, a control plane the subscription filter actually addresses,
|
||||||
/// and a fold that survives an inbound control edit.
|
/// and a fold that survives an inbound control edit.
|
||||||
@@ -1132,6 +1178,75 @@ mod tests {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reference counts anyone seen publishing anywhere, and a control edit
|
||||||
|
/// is the one publication that leaves no other trace: an npub that has
|
||||||
|
/// never joined a channel and never touched the Guestbook exists in no
|
||||||
|
/// other plane, so a fold that reads only those reads them as a stranger.
|
||||||
|
#[test]
|
||||||
|
fn a_control_editions_author_is_observed_as_a_member() {
|
||||||
|
smol::block_on(async {
|
||||||
|
let client = client();
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let signer = UniversalSigner::new(keys.clone());
|
||||||
|
let editor = Keys::generate();
|
||||||
|
|
||||||
|
let created = create(&client, &signer, &metadata("coop"))
|
||||||
|
.await
|
||||||
|
.expect("creates");
|
||||||
|
|
||||||
|
let community_head = created
|
||||||
|
.heads
|
||||||
|
.iter()
|
||||||
|
.find(|head| head.entity == *created.id.as_bytes())
|
||||||
|
.expect("a community head");
|
||||||
|
let writer = cord02::ControlWriter {
|
||||||
|
author: editor.public_key(),
|
||||||
|
read: control_group_key(&created.community_root, &created.id, cord02::ROOT_EPOCH)
|
||||||
|
.expect("a reading key"),
|
||||||
|
signer: control_signer_group_key(
|
||||||
|
&created.control_root.expect("a control root"),
|
||||||
|
&created.id,
|
||||||
|
cord02::ROOT_EPOCH,
|
||||||
|
)
|
||||||
|
.expect("a signing key"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The editor holds no grant, so the edit is inert: it supersedes
|
||||||
|
// nothing. It is still a publication by that npub.
|
||||||
|
let (wrap, _) = writer
|
||||||
|
.set_community_metadata(
|
||||||
|
&editor,
|
||||||
|
&created.id,
|
||||||
|
&metadata("coop two"),
|
||||||
|
Some(community_head),
|
||||||
|
None,
|
||||||
|
Timestamp::now().as_secs() + 1,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("publishes");
|
||||||
|
client.database().save_event(&wrap).await.expect("saves");
|
||||||
|
|
||||||
|
let snapshot = fold(&client, &created)
|
||||||
|
.await
|
||||||
|
.expect("folds")
|
||||||
|
.expect("a control plane");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
snapshot.members.contains(&editor.public_key()),
|
||||||
|
"an edition's author is a member the fold has seen publishing"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot
|
||||||
|
.control
|
||||||
|
.community
|
||||||
|
.as_ref()
|
||||||
|
.map(|metadata| metadata.name.as_str()),
|
||||||
|
Some("coop"),
|
||||||
|
"an unauthorized edit still changes nothing"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The other half of `create`: the membership must reach the account's
|
/// The other half of `create`: the membership must reach the account's
|
||||||
/// Community List, and a later create must union into it rather than replace
|
/// Community List, and a later create must union into it rather than replace
|
||||||
/// it (CORD-02 §8 read-modify-write).
|
/// it (CORD-02 §8 read-modify-write).
|
||||||
@@ -1313,15 +1428,6 @@ mod tests {
|
|||||||
assert_eq!(community_of(&subscription_id(&id)), Some(id));
|
assert_eq!(community_of(&subscription_id(&id)), Some(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A relay answers a longer REQ with `invalid subscription id length` but
|
|
||||||
/// `subscribe` still reports success, so an over-long id fails silently.
|
|
||||||
#[test]
|
|
||||||
fn a_community_subscription_id_fits_the_nip01_cap() {
|
|
||||||
let id = CommunityId::from_bytes([0x42; 32]);
|
|
||||||
|
|
||||||
assert!(subscription_id(&id).as_str().len() <= 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn event_at(at_ms: u64) -> Event {
|
fn event_at(at_ms: u64) -> Event {
|
||||||
EventBuilder::new(Kind::TextNote, "wrap")
|
EventBuilder::new(Kind::TextNote, "wrap")
|
||||||
.custom_created_at(Timestamp::from_secs(at_ms / 1000))
|
.custom_created_at(Timestamp::from_secs(at_ms / 1000))
|
||||||
|
|||||||
@@ -27,10 +27,9 @@ mod message;
|
|||||||
|
|
||||||
/// How near the top row a scroll has to come before the panel splices older history in.
|
/// How near the top row a scroll has to come before the panel splices older history in.
|
||||||
const LOAD_OLDER_THRESHOLD: usize = 20;
|
const LOAD_OLDER_THRESHOLD: usize = 20;
|
||||||
|
/// A repeat message within this window keeps its run, so it carries no avatar or name.
|
||||||
|
const RUN_WINDOW_MS: u64 = 300_000;
|
||||||
|
|
||||||
/// Why a room is not showing the messages it knows about.
|
|
||||||
///
|
|
||||||
/// "No messages yet" is a claim, and a room we cannot read is not an empty one.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Notice {
|
enum Notice {
|
||||||
Stranded,
|
Stranded,
|
||||||
@@ -59,23 +58,22 @@ impl fmt::Display for Notice {
|
|||||||
),
|
),
|
||||||
Notice::MissingKey(epoch) => write!(
|
Notice::MissingKey(epoch) => write!(
|
||||||
formatter,
|
formatter,
|
||||||
"Messages here can't be read yet — this channel's key for epoch {} is missing",
|
"Messages here can't be read yet, this channel's key for epoch {} is missing",
|
||||||
epoch.0
|
epoch.0
|
||||||
),
|
),
|
||||||
Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"),
|
Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"),
|
||||||
Notice::Unreadable(1) => {
|
Notice::Unreadable(1) => {
|
||||||
formatter.write_str("1 message here can't be read yet — no key we hold opens it")
|
formatter.write_str("1 message here can't be read yet, no key we hold opens it")
|
||||||
}
|
}
|
||||||
Notice::Unreadable(count) => write!(
|
Notice::Unreadable(count) => write!(
|
||||||
formatter,
|
formatter,
|
||||||
"{count} messages here can't be read yet — no key we hold opens them"
|
"{count} messages here can't be read yet, no key we hold opens them"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Notice {
|
impl Notice {
|
||||||
/// Whether the room still accepts writes, rather than only being readable.
|
|
||||||
fn writable(self) -> bool {
|
fn writable(self) -> bool {
|
||||||
matches!(self, Notice::Unreachable | Notice::Unreadable(_))
|
matches!(self, Notice::Unreachable | Notice::Unreadable(_))
|
||||||
}
|
}
|
||||||
@@ -653,6 +651,20 @@ impl CommunityPanel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the row at `index` opens a run from one author.
|
||||||
|
fn opens_run(&self, index: usize) -> bool {
|
||||||
|
let Some(current) = self.rows.get(index) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(previous) = index.checked_sub(1).and_then(|index| self.rows.get(index)) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
current.author != previous.author
|
||||||
|
|| current.at_ms.saturating_sub(previous.at_ms) > RUN_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
fn render_message(
|
fn render_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
@@ -667,7 +679,9 @@ impl CommunityPanel {
|
|||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
message::render(ix, message, cx)
|
let show_author = self.opens_run(ix - 1);
|
||||||
|
|
||||||
|
message::render(ix, message, show_author, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use common::TimestampExt;
|
|||||||
use community::ChatMessage;
|
use community::ChatMessage;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div,
|
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
@@ -13,7 +13,7 @@ use theme::ActiveTheme;
|
|||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::{StyledExt, h_flex, v_flex};
|
use ui::{StyledExt, h_flex, v_flex};
|
||||||
|
|
||||||
pub(crate) fn render(ix: usize, message: &ChatMessage, cx: &App) -> AnyElement {
|
pub(crate) fn render(ix: usize, message: &ChatMessage, show_author: bool, cx: &App) -> AnyElement {
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let author = persons.read(cx).get(&message.author, cx);
|
let author = persons.read(cx).get(&message.author, cx);
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
@@ -29,35 +29,36 @@ pub(crate) fn render(ix: usize, message: &ChatMessage, cx: &App) -> AnyElement {
|
|||||||
.items_start()
|
.items_start()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.when(!hide_avatar, |this| {
|
.when(!hide_avatar, |this| {
|
||||||
this.child(
|
if show_author {
|
||||||
Avatar::new(author.avatar())
|
this.child(
|
||||||
.seed(author.avatar_seed())
|
Avatar::new(author.avatar())
|
||||||
.flex_shrink_0(),
|
.seed(author.avatar_seed())
|
||||||
)
|
.flex_shrink_0(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.child(div().flex_shrink_0().w(px(32.)))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
.child(
|
.when(show_author, |this| {
|
||||||
h_flex()
|
this.child(
|
||||||
.gap_2()
|
h_flex()
|
||||||
.text_sm()
|
.gap_2()
|
||||||
.child(div().font_semibold().child(author.name()))
|
.text_sm()
|
||||||
.child(
|
.text_color(cx.theme().text_placeholder)
|
||||||
div()
|
.child(div().font_semibold().child(author.name()))
|
||||||
.text_color(cx.theme().text_placeholder)
|
.child(
|
||||||
.child(Timestamp::from_secs(message.at_ms / 1000).to_ago()),
|
Timestamp::from_secs(message.at_ms / 1000).to_human_time(),
|
||||||
)
|
|
||||||
.when(message.edited_at.is_some(), |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().text_placeholder)
|
|
||||||
.child("(edited)"),
|
|
||||||
)
|
)
|
||||||
}),
|
.when(message.edited_at.is_some(), |this| {
|
||||||
)
|
this.child(div().child("(edited)"))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(content(message, cx))
|
.child(content(message, cx))
|
||||||
.when(!message.reactions.is_empty(), |this| {
|
.when(!message.reactions.is_empty(), |this| {
|
||||||
this.child(reactions(message, cx))
|
this.child(reactions(message, cx))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
|
use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
|
||||||
use crate::cord05::{ChannelGrant, CommunityInvite};
|
use crate::cord05::{ChannelGrant, CommunityInvite};
|
||||||
use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union};
|
use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union};
|
||||||
use crate::{ChannelId, CommunityId, Epoch, Extra};
|
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
||||||
|
|
||||||
pub const KIND_COMMUNITY_LIST: u16 = 33302;
|
pub const KIND_COMMUNITY_LIST: u16 = 33302;
|
||||||
pub const MAX_MEMBERSHIPS: usize = 50;
|
pub const MAX_MEMBERSHIPS: usize = 50;
|
||||||
@@ -171,6 +171,70 @@ impl CommunityList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct RetainedRoot {
|
||||||
|
pub epoch: Epoch,
|
||||||
|
pub key: [u8; 32],
|
||||||
|
pub control_pk: Option<PublicKey>,
|
||||||
|
/// The npub whose Refounding minted this epoch.
|
||||||
|
pub refounder: Option<PublicKey>,
|
||||||
|
/// The epoch-seconds the superseding rotation published.
|
||||||
|
pub retired_at: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JoinMaterial {
|
||||||
|
/// The npub whose Refounding minted `root_epoch`, when the document names one.
|
||||||
|
pub fn refounder(&self) -> Option<PublicKey> {
|
||||||
|
self.extra.get("refounder").and_then(named_public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prior root epochs the entry retains, with what the document knows of each.
|
||||||
|
pub fn held_roots(&self) -> Vec<RetainedRoot> {
|
||||||
|
let Some(roots) = self
|
||||||
|
.extra
|
||||||
|
.get("held_roots")
|
||||||
|
.and_then(|roots| roots.as_array())
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut retained = Vec::with_capacity(roots.len());
|
||||||
|
|
||||||
|
for root in roots {
|
||||||
|
let Some(fields) = root.as_object() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(epoch) = fields.get("epoch").and_then(|epoch| epoch.as_u64()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(key) = fields
|
||||||
|
.get("key")
|
||||||
|
.and_then(|key| key.as_str())
|
||||||
|
.and_then(|key| decode_hex_32(&key.to_ascii_lowercase()).ok())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
retained.push(RetainedRoot {
|
||||||
|
epoch: Epoch(epoch),
|
||||||
|
key,
|
||||||
|
control_pk: fields.get("control_pk").and_then(named_public_key),
|
||||||
|
refounder: fields.get("refounder").and_then(named_public_key),
|
||||||
|
retired_at: fields.get("retired_at").and_then(|at| at.as_u64()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retained
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hex npub the document names, or nothing when the field is not one.
|
||||||
|
fn named_public_key(value: &serde_json::Value) -> Option<PublicKey> {
|
||||||
|
PublicKey::from_hex(value.as_str()?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial {
|
pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial {
|
||||||
JoinMaterial {
|
JoinMaterial {
|
||||||
community_id: invite.community_id,
|
community_id: invite.community_id,
|
||||||
@@ -644,6 +708,8 @@ fn crypto_error(error: impl fmt::Display) -> ListError {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn id(byte: u8) -> CommunityId {
|
fn id(byte: u8) -> CommunityId {
|
||||||
@@ -704,6 +770,62 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A peer's refounder and retained roots ride in the unknown-field map —
|
||||||
|
/// they are extensions, not §8 named fields — so the round trip has to keep
|
||||||
|
/// them, and the accessors have to read them out of it.
|
||||||
|
#[test]
|
||||||
|
fn the_wire_round_trips_a_refounder_and_the_retained_roots() {
|
||||||
|
let owner = Keys::generate().public_key();
|
||||||
|
let refounder = Keys::generate().public_key();
|
||||||
|
let mut current = material(id(0x11), owner, "Room", 3);
|
||||||
|
|
||||||
|
current
|
||||||
|
.extra
|
||||||
|
.insert("refounder".to_owned(), Value::String(refounder.to_hex()));
|
||||||
|
current.extra.insert(
|
||||||
|
"held_roots".to_owned(),
|
||||||
|
json!([
|
||||||
|
{
|
||||||
|
"epoch": 2,
|
||||||
|
"key": "77".repeat(32),
|
||||||
|
"refounder": refounder.to_hex(),
|
||||||
|
"retired_at": 1_700_000_000,
|
||||||
|
},
|
||||||
|
{ "epoch": 1, "key": "88".repeat(32) },
|
||||||
|
{ "epoch": 4 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let wire = serde_json::to_string(&WireSnapshot::encode(¤t).expect("encodes"))
|
||||||
|
.expect("serializes");
|
||||||
|
let decoded = serde_json::from_str::<WireSnapshot>(&wire)
|
||||||
|
.expect("parses")
|
||||||
|
.decode(id(0x11))
|
||||||
|
.expect("decodes");
|
||||||
|
|
||||||
|
assert_eq!(decoded.refounder(), Some(refounder));
|
||||||
|
assert_eq!(
|
||||||
|
decoded.held_roots(),
|
||||||
|
vec![
|
||||||
|
RetainedRoot {
|
||||||
|
epoch: Epoch(2),
|
||||||
|
key: [0x77; 32],
|
||||||
|
control_pk: None,
|
||||||
|
refounder: Some(refounder),
|
||||||
|
retired_at: Some(1_700_000_000),
|
||||||
|
},
|
||||||
|
RetainedRoot {
|
||||||
|
epoch: Epoch(1),
|
||||||
|
key: [0x88; 32],
|
||||||
|
control_pk: None,
|
||||||
|
refounder: None,
|
||||||
|
retired_at: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"a root without a key is not a root"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
|
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
|
||||||
let owner = Keys::generate().public_key();
|
let owner = Keys::generate().public_key();
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ pub fn build_message(
|
|||||||
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
|
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `parent` is the immediate parent; a `None` root means the parent is the thread's root.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_comment(
|
pub fn build_comment(
|
||||||
author: PublicKey,
|
author: PublicKey,
|
||||||
@@ -146,12 +145,14 @@ pub fn build_comment(
|
|||||||
|
|
||||||
tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()]));
|
tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()]));
|
||||||
tags.push(reply_tag(TAG_ROOT, &root.reply));
|
tags.push(reply_tag(TAG_ROOT, &root.reply));
|
||||||
|
|
||||||
if let Some(root_author) = root.reply.author {
|
if let Some(root_author) = root.reply.author {
|
||||||
tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()]));
|
tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()]));
|
tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()]));
|
||||||
tags.push(reply_tag(TAG_TARGET, &parent.reply));
|
tags.push(reply_tag(TAG_TARGET, &parent.reply));
|
||||||
|
|
||||||
if let Some(parent_author) = parent.reply.author {
|
if let Some(parent_author) = parent.reply.author {
|
||||||
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
|
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -312,13 +312,19 @@ as an inline row only when its author passes
|
|||||||
## Membership
|
## Membership
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let states = cord02::guestbook::coalesce(&rumors, now_ms, Some(&refounder_pk), |actor, target, citation| {
|
let states = cord02::guestbook::coalesce(&rumors, now_ms, &refounders, |actor, target, citation| {
|
||||||
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
|
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
|
||||||
&& control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK)
|
&& control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK)
|
||||||
});
|
});
|
||||||
let members = cord02::guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new());
|
let members = cord02::guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new());
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `refounders` is the set of npubs whose rotations minted an epoch this client
|
||||||
|
verified (`CommunityState.refounders`) — a snapshot chunk is honored only from
|
||||||
|
one of them, and an empty set honors none. A rotation that delivered this
|
||||||
|
client no key still names its minter, so the set is filled by any base rotation
|
||||||
|
whose continuity verifies against a root held (`rekey::walk`); a List entry's
|
||||||
|
`refounder`, read by `list::JoinMaterial::refounder`, is the other source.
|
||||||
- `observed` is npub → ms for every author this client has seen publish anything
|
- `observed` is npub → ms for every author this client has seen publish anything
|
||||||
usable, which is what makes a member visible before their Join arrives. Only
|
usable, which is what makes a member visible before their Join arrives. Only
|
||||||
count it forward.
|
count it forward.
|
||||||
@@ -553,6 +559,14 @@ fragment at every index below it. `merge` resolves a `frags` disagreement to the
|
|||||||
larger value. (`13302`, the single-event List, is retired by the spec — a
|
larger value. (`13302`, the single-event List, is retired by the spec — a
|
||||||
replaceable kind cannot fragment.)
|
replaceable kind cannot fragment.)
|
||||||
|
|
||||||
|
A join material may also carry two hex extensions this crate does not write but
|
||||||
|
does read, because they are the only place a device that never held a rotation can
|
||||||
|
learn who minted an epoch: `refounder` names the npub whose Refounding minted the
|
||||||
|
entry's `root_epoch`, and `held_roots` is the retained prior epochs
|
||||||
|
(`[{epoch, key, refounder?, control_pk?, retired_at?}]`). `JoinMaterial::refounder()`
|
||||||
|
and `JoinMaterial::held_roots()` decode both; unknown fields are round-tripped
|
||||||
|
regardless, so a document that carries them keeps them.
|
||||||
|
|
||||||
The payload's 32-byte values are **unpadded base64url at every depth**, which is
|
The payload's 32-byte values are **unpadded base64url at every depth**, which is
|
||||||
section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on
|
section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on
|
||||||
every serialization, so its output is always the canonical 43-character spelling;
|
every serialization, so its output is always the canonical 43-character spelling;
|
||||||
|
|||||||
Reference in New Issue
Block a user