add control fold and roster
This commit is contained in:
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user