add control fold and roster

This commit is contained in:
2026-09-16 20:05:51 +07:00
parent d926c1e3ea
commit 4329385abe
8 changed files with 2016 additions and 111 deletions
+167
View File
@@ -3,6 +3,7 @@ use std::fmt;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::decode_hex_32;
@@ -312,6 +313,92 @@ pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
.map(|(index, _)| index)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HeadSelection {
pub head: Option<usize>,
pub gap: bool,
}
/// The head to prefer for one entity, given what this client already committed to.
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
let Some(floor) = floor else {
return HeadSelection {
head: bootstrap_head(editions),
gap: false,
};
};
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
if anchored.anchored {
return HeadSelection {
head: anchored.head,
gap: anchored.gap,
};
}
if anchored.head.is_none() && !anchored.gap {
return HeadSelection::default();
}
let fork = editions
.iter()
.enumerate()
.filter(|(_, edition)| edition.version == floor.version)
.min_by_key(|(_, edition)| edition.tiebreak_id);
let winner = match fork {
Some((_, edition))
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
{
edition.self_hash
}
_ => {
return HeadSelection {
head: None,
gap: true,
};
}
};
let refolded = fold(editions, floor.version, Some(&winner));
if refolded.anchored {
HeadSelection {
head: refolded.head,
gap: refolded.gap,
}
} else {
HeadSelection {
head: None,
gap: true,
}
}
}
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityHead {
pub entity: [u8; 32],
pub version: u64,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
impl From<&ParsedEdition> for EntityHead {
fn from(edition: &ParsedEdition) -> Self {
Self {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
}
}
}
/// Every entity's committed head, keyed by coordinate.
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
fn canonical_decimal(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
@@ -366,6 +453,86 @@ fn value<'a>(
mod tests {
use super::*;
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
EditionMeta {
version,
self_hash: [hash; 32],
prev,
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
}
}
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
EntityHead {
entity: [0x11; 32],
version,
self_hash: [hash; 32],
rumor_id: EventId::from_byte_array([rumor; 32]),
}
}
#[test]
fn fold_picks_the_head_from_the_chain_and_the_floor() {
let chain = [
meta(1, None, 0xa1, 1),
meta(2, Some([0xa1; 32]), 0xa2, 2),
meta(3, Some([0xa2; 32]), 0xa3, 3),
];
let folded = fold(&chain, 0, None);
assert_eq!(folded.head, Some(2));
assert!(!folded.gap && folded.anchored);
// A missing link stops the walk at the last contiguous edition.
let gapped = fold(&[chain[0], chain[2]], 0, None);
assert_eq!(gapped.head, Some(0));
assert!(gapped.gap && gapped.anchored);
// Everything below the held floor is a stale relay, not a gap.
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
assert_eq!(stale.head, None);
assert!(!stale.gap && !stale.anchored);
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
assert_eq!(
fold(&fork, 0, None).head,
Some(1),
"the lower rumor id wins"
);
let forked = [fork[0], fork[1], chain[1], chain[2]];
assert_eq!(fold(&forked, 0, None).head, Some(3));
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
// longer resolves is a withholding.
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
assert_eq!(
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
Some(0)
);
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
assert_eq!(refused.head, None);
assert!(refused.gap);
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
assert_eq!(bootstrap_head(&[dangling]), Some(0));
assert_eq!(fold_head(&[dangling], None).head, Some(0));
// A fork at the floor's own version converges to the lower rumor id when that is
// genuinely earlier than what we hold, and the chain above it re-anchors.
let forked = [
meta(2, Some([0xa1; 32]), 0xb2, 3),
meta(3, Some([0xb2; 32]), 0xb3, 4),
];
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
assert_eq!(converged.head, Some(1));
assert!(!converged.gap);
// A fork that is not earlier than the held head is refused.
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
}
#[test]
fn edition_hash_matches_the_cross_client_vector() {
let entity = [0x11u8; 32];