This commit is contained in:
2026-09-22 20:15:15 +07:00
parent 1c5ef58049
commit 706cc72c1f
14 changed files with 1021 additions and 104 deletions
+21 -11
View File
@@ -187,10 +187,15 @@ pub fn open(
Ok((opened, rumor))
}
/// Coalesce the guestbook flat: one final state per npub, the latest entry
/// winning by millisecond time, ties broken by the lower rumor id.
///
/// `snapshot_authorities` are the npubs whose refounding is known to have minted an epoch this client reads.
/// A snapshot chunk is honored only from one of them, and an empty set honors no snapshot at all.
pub fn coalesce(
rumors: &[GuestbookRumor],
now_ms: u64,
snapshot_authority: Option<&PublicKey>,
snapshot_authorities: &BTreeSet<PublicKey>,
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
) -> BTreeMap<PublicKey, MemberState> {
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
@@ -250,7 +255,7 @@ pub fn coalesce(
at_ms,
..
} => {
if snapshot_authority != Some(refounder) {
if !snapshot_authorities.contains(refounder) {
continue;
}
@@ -457,6 +462,11 @@ mod tests {
CommunityId::from_bytes([0x11u8; 32])
}
/// The refounders a fold is told about: a snapshot seeds members on theirs alone.
fn refounders(keys: &[&Keys]) -> BTreeSet<PublicKey> {
keys.iter().map(|keys| keys.public_key()).collect()
}
fn group() -> GroupKey {
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
}
@@ -533,7 +543,7 @@ mod tests {
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
};
let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick);
let states = coalesce(&rumors, AT + 8_000, &refounders(&[&carol]), can_kick);
assert_eq!(
states.get(&alice.public_key()),
@@ -562,7 +572,7 @@ mod tests {
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
assert_eq!(
coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick),
coalesce(&reversed, AT + 8_000, &refounders(&[&carol]), can_kick),
states,
"arrival order cannot change the fold"
);
@@ -649,7 +659,7 @@ mod tests {
),
];
let states = coalesce(&rumors, AT + 1_000, None, can_kick);
let states = coalesce(&rumors, AT + 1_000, &BTreeSet::new(), can_kick);
assert_eq!(
states.get(&kicked.public_key()),
@@ -686,21 +696,21 @@ mod tests {
)
.remove(0);
for authority in [None, Some(refounder.public_key())] {
for authority in [BTreeSet::new(), refounders(&[&refounder])] {
let states = coalesce(
&[
publish(&by_refounder, &refounder),
publish(&by_impostor, &impostor),
],
AT + 1_000,
authority.as_ref(),
&authority,
|_, _, _| true,
);
assert_eq!(
states.contains_key(&seeded.public_key()),
authority.is_some(),
"only the epoch's refounder seeds, and there is no owner fallback"
!authority.is_empty(),
"only a known refounder seeds, and there is no owner fallback"
);
assert!(
!states.contains_key(&smuggled.public_key()),
@@ -724,11 +734,11 @@ mod tests {
&member,
);
assert!(
coalesce(&[future], AT, None, |_, _, _| true).is_empty(),
coalesce(&[future], AT, &BTreeSet::new(), |_, _, _| true).is_empty(),
"an entry more than an hour ahead is dropped"
);
assert_eq!(
coalesce(&[horizon], AT, None, |_, _, _| true).len(),
coalesce(&[horizon], AT, &BTreeSet::new(), |_, _, _| true).len(),
1,
"the horizon itself is skew, not forgery"
);
+2 -1
View File
@@ -7,5 +7,6 @@ pub mod state;
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
pub(crate) use types::Extra;
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
pub use utils::decode_hex_32;
pub use utils::derive::{self, GroupKey};
pub(crate) use utils::{decode_hex_32, decode_hex_lower, fill_random, random_32};
pub(crate) use utils::{decode_hex_lower, fill_random, random_32};
+9 -6
View File
@@ -22,8 +22,7 @@ pub const STATE_PREFIX: &str = "concord/";
pub struct HeldKey {
pub epoch: Epoch,
pub key: [u8; 32],
/// The publish time of the rotation that superseded this key: a wrap
/// sealed under it later than this does not read.
/// The publish time of the rotation that superseded this key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<Timestamp>,
}
@@ -130,12 +129,15 @@ pub struct CommunityState {
skip_serializing_if = "BTreeMap::is_empty"
)]
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
/// Root epochs superseded by a rotation we adopted, newest first.
/// Root epochs the community has rotated past that this client still holds.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub held_roots: Vec<HeldRoot>,
/// The epoch a channel rotation removed us at.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
/// The npubs whose rotation minted an epoch of this community we verified.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub refounders: BTreeSet<PublicKey>,
/// The base epoch we were excluded at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_at: Option<Epoch>,
@@ -219,6 +221,7 @@ impl CommunityState {
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
refounders: BTreeSet::new(),
removed_at: None,
stranded: false,
dissolved: false,
@@ -273,6 +276,7 @@ impl CommunityState {
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
refounders: BTreeSet::new(),
removed_at: None,
stranded: false,
dissolved: false,
@@ -317,12 +321,10 @@ impl CommunityState {
return keys;
}
// A public channel derives from the community root, so its history
// spans every root epoch the rotation kept a floor for.
self.roots()
.into_iter()
.map(|root| HeldKey {
epoch: held.epoch,
epoch: root.epoch,
key: root.key,
retired_at: root.retired_at,
})
@@ -499,6 +501,7 @@ mod tests {
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
refounders: BTreeSet::new(),
removed_at: None,
stranded: false,
dissolved: false,
+3 -1
View File
@@ -9,8 +9,10 @@ use serde::Serialize;
use crate::Extra;
/// Decode a 64-character lowercase-hex string into 32 bytes.
///
/// Uppercase and other non-canonical spellings are rejected.
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
pub fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
decode_hex_lower::<32>(value)
}