Files
coop/crates/concord/src/edition.rs
T
2026-09-16 20:46:22 +07:00

560 lines
16 KiB
Rust

use std::collections::BTreeMap;
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;
use crate::stream::build_rumor_secs;
pub const KIND_CONTROL: u16 = 3308;
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
/// Entity types an edition can address (CORD-02 Appendix B).
pub mod vsk {
pub const COMMUNITY_METADATA: &str = "0";
pub const ROLE: &str = "1";
pub const CHANNEL_METADATA: &str = "2";
pub const GRANT: &str = "3";
pub const BANLIST: &str = "4";
pub const INVITE_LIVE: &str = "6";
pub const INVITE_LINKS: &str = "8";
pub const INVITE_REVOKED: &str = "9";
pub const DISSOLVED: &str = "10";
pub const PINS: &str = "11";
}
const TAG_SUBKIND: &str = "vsk";
const TAG_ENTITY: &str = "eid";
const TAG_VERSION: &str = "ev";
const TAG_PREV: &str = "ep";
const TAG_CITATION: &str = "vac";
#[derive(Debug)]
pub enum EditionError {
BadKind(u16),
BadField(&'static str),
Duplicate(&'static str),
Missing(&'static str),
}
impl fmt::Display for EditionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"),
EditionError::BadField(name) => write!(f, "malformed edition field: {name}"),
EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"),
EditionError::Missing(name) => write!(f, "missing edition field: {name}"),
}
}
}
impl std::error::Error for EditionError {}
/// A `vac` citation: the Grant edition an actor claims rank under, pinned by
/// coordinate, version and hash. It is a sync floor, not the verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthorityCitation {
pub entity: [u8; 32],
pub version: u64,
pub hash: [u8; 32],
}
#[derive(Debug, Clone)]
pub struct ParsedEdition {
pub author: PublicKey,
pub subkind: String,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: String,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
pub struct EditionFields<'a> {
pub author: PublicKey,
pub subkind: &'a str,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: &'a str,
pub at_secs: u64,
}
fn signing_bytes(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> Vec<u8> {
let mut bytes =
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
bytes.extend_from_slice(EDITION_LABEL);
bytes.extend_from_slice(entity);
bytes.extend_from_slice(&version.to_be_bytes());
match prev {
Some(prev) => {
bytes.push(1);
bytes.extend_from_slice(prev);
}
None => {
bytes.push(0);
bytes.extend_from_slice(&[0u8; 32]);
}
}
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
bytes.extend_from_slice(content);
bytes
}
pub fn edition_hash(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> [u8; 32] {
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
}
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
let mut tags = vec![
Tag::custom(TAG_SUBKIND, [fields.subkind]),
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
];
if let Some(prev) = fields.prev {
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
}
if let Some(citation) = fields.citation {
tags.push(Tag::custom(
TAG_CITATION,
[
HEXLOWER.encode(&citation.entity),
citation.version.to_string(),
HEXLOWER.encode(&citation.hash),
],
));
}
build_rumor_secs(
KIND_CONTROL,
fields.author,
fields.content,
tags,
fields.at_secs,
)
}
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
let kind = rumor.kind.as_u16();
if kind != KIND_CONTROL {
return Err(EditionError::BadKind(kind));
}
let subkind = value(rumor, TAG_SUBKIND)?
.ok_or(EditionError::Missing(TAG_SUBKIND))?
.to_owned();
if canonical_decimal(&subkind).is_none() {
return Err(EditionError::BadField(TAG_SUBKIND));
}
let entity = hex32(
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
TAG_ENTITY,
)?;
let version =
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
.ok_or(EditionError::BadField(TAG_VERSION))?;
let prev = match value(rumor, TAG_PREV)? {
Some(raw) => Some(hex32(raw, TAG_PREV)?),
None => None,
};
let citation = match fields(rumor, TAG_CITATION)? {
Some(fields) if fields.len() == 4 => Some(AuthorityCitation {
entity: hex32(&fields[1], TAG_CITATION)?,
version: canonical_decimal(&fields[2]).ok_or(EditionError::BadField(TAG_CITATION))?,
hash: hex32(&fields[3], TAG_CITATION)?,
}),
Some(_) => return Err(EditionError::BadField(TAG_CITATION)),
None => None,
};
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
Ok(ParsedEdition {
author: rumor.pubkey,
subkind,
entity,
version,
prev,
citation,
content: rumor.content.clone(),
self_hash,
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditionMeta {
pub version: u64,
pub self_hash: [u8; 32],
pub prev: Option<[u8; 32]>,
pub tiebreak_id: EventId,
}
impl From<&ParsedEdition> for EditionMeta {
fn from(edition: &ParsedEdition) -> Self {
Self {
version: edition.version,
self_hash: edition.self_hash,
prev: edition.prev,
tiebreak_id: edition.rumor_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FoldResult {
pub head: Option<usize>,
pub gap: bool,
pub anchored: bool,
}
/// The highest version whose chain is intact, given a held floor.
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() {
if edition.version < floor {
continue;
}
match by_version.get(&edition.version) {
Some(&current) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
_ => {
by_version.insert(edition.version, index);
}
}
}
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
return FoldResult::default();
};
let lowest = editions[lowest_index];
let anchored = if floor == 0 {
lowest_version == 1 && lowest.prev.is_none()
} else if lowest_version == floor {
floor_hash == Some(&lowest.self_hash)
} else if lowest_version == floor + 1 {
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
} else {
false
};
let mut head = Some(lowest_index);
let mut gap = !anchored;
let mut previous_version = lowest_version;
let mut previous_hash = lowest.self_hash;
for (&version, &index) in by_version.range(lowest_version + 1..) {
let edition = editions[index];
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
head = Some(index);
previous_version = version;
previous_hash = edition.self_hash;
} else {
gap = true;
break;
}
}
FoldResult {
head,
gap,
anchored,
}
}
/// The highest version overall, ignoring contiguity.
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions
.iter()
.enumerate()
.reduce(|(best_index, best), (index, candidate)| {
let supersedes = candidate.version > best.version
|| (candidate.version == best.version && candidate.tiebreak_id < best.tiebreak_id);
if supersedes {
(index, candidate)
} else {
(best_index, best)
}
})
.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>;
pub(crate) fn canonical_decimal(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
if raw.len() > 1 && raw.starts_with('0') {
return None;
}
raw.parse().ok()
}
fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> {
decode_hex_32(raw).map_err(|_| EditionError::BadField(name))
}
fn fields<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, EditionError> {
let mut found: Option<&[String]> = None;
for tag in rumor.tags.iter() {
let tag_fields = tag.as_slice();
if tag_fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(EditionError::Duplicate(name));
}
found = Some(tag_fields);
}
Ok(found)
}
fn value<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a str>, EditionError> {
match fields(rumor, name)? {
Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())),
Some(_) => Err(EditionError::BadField(name)),
None => Ok(None),
}
}
#[cfg(test)]
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];
assert_eq!(
HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")),
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
);
// The golden vector only exercises the absent-prev encoding; pin the
// present-prev branch structurally so a swapped flag stays visible.
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
assert_eq!(
bytes.len(),
8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + 5
);
assert_eq!(&bytes[8..8 + EDITION_LABEL.len()], EDITION_LABEL);
assert_eq!(
bytes[8 + EDITION_LABEL.len() + 32..][..8],
1u64.to_be_bytes()
);
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
}
}