This commit is contained in:
2026-09-22 20:34:01 +07:00
parent 706cc72c1f
commit 08c1687d87
6 changed files with 433 additions and 36 deletions
+123 -1
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
use crate::cord05::{ChannelGrant, CommunityInvite};
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 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 {
JoinMaterial {
community_id: invite.community_id,
@@ -644,6 +708,8 @@ fn crypto_error(error: impl fmt::Display) -> ListError {
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use super::*;
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(&current).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]
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
let owner = Keys::generate().public_key();