Files
coop/crates/community/src/rekey.rs
T
2026-09-29 21:53:50 +00:00

1212 lines
37 KiB
Rust

use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, Mutex, MutexGuard};
use anyhow::{Result, bail};
use concord::cord01::{self, KIND_WRAP_EPHEMERAL, SealForm};
use concord::cord04::roles::{CommunityRoles, Permissions};
use concord::cord04::{self, AuthorityCitation};
use concord::cord06::{
self, Continuity, Refounding, RekeyScope, Rotation, RotationKey, RotationPlan,
};
use concord::derive::{GroupKey, channel_rekey_group_key, epoch_key_commitment};
use concord::state::{CommunityState, HeldKey};
use concord::{ChannelId, CommunityId, Epoch};
use nostr_sdk::prelude::*;
use crate::sync::{self, PlaneKind};
/// Epochs ahead of a held epoch a rotation is looked for.
pub const REKEY_LOOKAHEAD: u64 = 8;
/// How much of what a relay stores a rekey watch replays.
const REKEY_REPLAY: usize = 200;
/// One address the rekey watch asks a relay for, and what a wrap at it means.
#[derive(Debug, Clone)]
pub struct Watch {
pub address: PublicKey,
pub group: GroupKey,
pub scope: RekeyScope,
pub epoch: Epoch,
}
/// The permission a rotation of `scope` is judged under, by whoever reads it and
/// by this client when it publishes one.
fn permissions(scope: RekeyScope) -> &'static [u64] {
match scope {
RekeyScope::Base => &[Permissions::BAN],
RekeyScope::Channel(_) => &[Permissions::MANAGE_CHANNELS, Permissions::BAN],
}
}
/// Every address a community's rotations can arrive at.
///
/// The base scope watches the epoch after *every* held root, not only the
/// current one: a rotation published while this client was away is read from
/// the root it stepped off, and the npub that minted an epoch is only knowable
/// from the rotation that minted it.
pub fn watches(state: &CommunityState) -> Result<Vec<Watch>> {
let mut watches = Vec::new();
let roots = state.roots();
for root in &roots {
let next = Epoch(root.epoch.0 + 1);
let group = cord06::rekey_group(RekeyScope::Base, &root.key, &state.id, next)?;
watches.push(Watch {
address: group.pk(),
group,
scope: RekeyScope::Base,
epoch: next,
});
}
for channel in &state.channels {
if !channel.private {
continue;
}
for root in &roots {
for ahead in 1..=REKEY_LOOKAHEAD {
let epoch = Epoch(channel.epoch.0 + ahead);
let group = channel_rekey_group_key(&root.key, &channel.id, epoch)?;
watches.push(Watch {
address: group.pk(),
group,
scope: RekeyScope::Channel(channel.id),
epoch,
});
}
}
}
Ok(watches)
}
pub fn watch_filter(watches: &[Watch]) -> Filter {
Filter::new()
.kind(Kind::GiftWrap)
.authors(watches.iter().map(|watch| watch.address))
.limit(REKEY_REPLAY)
}
/// The subscription carrying a community's rekey watch.
pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
SubscriptionId::new(format!("rekey-{}", &id.to_hex()[..32]))
}
/// Which community a rekey watch subscription belongs to.
#[derive(Default)]
pub struct WatchRegistry {
watches: Arc<Mutex<HashMap<SubscriptionId, CommunityId>>>,
}
impl Clone for WatchRegistry {
fn clone(&self) -> Self {
Self {
watches: Arc::clone(&self.watches),
}
}
}
impl WatchRegistry {
pub fn register(&self, id: SubscriptionId, community: CommunityId) {
self.lock().insert(id, community);
}
pub fn community_of(&self, id: &SubscriptionId) -> Option<CommunityId> {
self.lock().get(id).copied()
}
/// Forget every watch, because the account that installed them is gone.
pub fn clear(&self) {
self.lock().clear();
}
fn lock(&self) -> MutexGuard<'_, HashMap<SubscriptionId, CommunityId>> {
match self.watches.lock() {
Ok(watches) => watches,
Err(poisoned) => poisoned.into_inner(),
}
}
}
/// What one adoption pass learned, ready to be folded into the held state.
#[derive(Debug, Default)]
pub struct Adoptions {
pub base: Option<BaseAdoption>,
pub channels: Vec<ChannelAdoption>,
/// Channels a complete rotation removed us from, with the epoch that did it.
pub cuts: Vec<(ChannelId, Epoch)>,
/// The base epoch a complete rotation excluded us at.
pub removed_at: Option<Epoch>,
pub stranded: bool,
/// The npubs whose rotations minted an epoch of this community.
pub refounders: BTreeSet<PublicKey>,
}
impl Adoptions {
pub fn is_empty(&self) -> bool {
self.base.is_none()
&& self.channels.is_empty()
&& self.cuts.is_empty()
&& self.removed_at.is_none()
&& !self.stranded
&& self.refounders.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct BaseAdoption {
pub epoch: Epoch,
pub key: [u8; 32],
pub control_pk: Option<PublicKey>,
/// The new Control Plane signing root, delivered to staff only.
pub control_root: Option<[u8; 32]>,
pub stepped: Vec<HeldKey>,
}
#[derive(Debug, Clone)]
pub struct ChannelAdoption {
pub channel: ChannelId,
pub epoch: Epoch,
pub key: [u8; 32],
/// The keys this rotation stepped off, newest first.
pub stepped: Vec<HeldKey>,
}
/// Read every rekey wrap the local database holds and adopt what is admissible.
pub async fn adopt<S>(
client: &Client,
state: &CommunityState,
roles: &CommunityRoles,
signer: &S,
me: PublicKey,
) -> Result<Adoptions>
where
S: AsyncNip44 + ?Sized,
{
let watches = watches(state)?;
let mut authors: BTreeSet<PublicKey> = BTreeSet::new();
for watch in &watches {
authors.insert(watch.address);
}
if authors.is_empty() {
return Ok(Adoptions::default());
}
let wraps = client
.database()
.query(Filter::new().kind(Kind::GiftWrap).authors(authors))
.await?;
let mut chunks = Vec::new();
let mut published: BTreeMap<RotationKey, u64> = BTreeMap::new();
for wrap in &wraps {
let Some(watch) = watches.iter().find(|watch| watch.address == wrap.pubkey) else {
continue;
};
let Ok(opened) = cord01::open_wrap(wrap, &watch.group) else {
continue;
};
let Ok(chunk) = cord06::parse_rekey_chunk(&opened) else {
continue;
};
if chunk.scope != watch.scope || chunk.new_epoch != watch.epoch {
continue;
}
let at_ms = wrap.created_at.as_secs().saturating_mul(1000);
published
.entry(chunk.correlation())
.and_modify(|held| *held = (*held).min(at_ms))
.or_insert(at_ms);
chunks.push(chunk);
}
if chunks.is_empty() {
return Ok(Adoptions::default());
}
let rotations = cord06::collect_rotations(&chunks);
let mut adoptions = Adoptions::default();
let mut base = walk(
RekeyScope::Base,
permissions(RekeyScope::Base),
state.root_epoch,
state.community_root,
state,
roles,
signer,
me,
&rotations,
&published,
)
.await?;
let mut refounders: BTreeSet<PublicKey> = base.refounders.clone();
for root in state.roots().into_iter().skip(1) {
let prior = walk(
RekeyScope::Base,
permissions(RekeyScope::Base),
root.epoch,
root.key,
state,
roles,
signer,
me,
&rotations,
&published,
)
.await?;
refounders.extend(prior.refounders.iter().copied());
let Some(adopted) = prior.adopted else {
continue;
};
if base
.adopted
.as_ref()
.is_none_or(|held| adopted.epoch > held.epoch)
{
base.adopted = Some(adopted);
}
}
if let Some(step) = base.adopted {
adoptions.base = Some(BaseAdoption {
epoch: step.epoch,
key: step.key,
control_pk: step.control_pk,
control_root: step.control_root,
stepped: step.stepped,
});
}
adoptions.refounders = refounders;
adoptions.removed_at = base.removed_at;
adoptions.stranded = base.stranded;
for channel in &state.channels {
if !channel.private {
continue;
}
let Some((held_epoch, held_key)) = channel.current() else {
continue;
};
let step = walk(
RekeyScope::Channel(channel.id),
permissions(RekeyScope::Channel(channel.id)),
held_epoch,
held_key,
state,
roles,
signer,
me,
&rotations,
&published,
)
.await?;
if let Some(adopted) = step.adopted {
adoptions.channels.push(ChannelAdoption {
channel: channel.id,
epoch: adopted.epoch,
key: adopted.key,
stepped: adopted.stepped,
});
}
if let Some(epoch) = step.removed_at {
adoptions.cuts.push((channel.id, epoch));
}
}
Ok(adoptions)
}
/// A rotation this client is asked to perform.
#[derive(Debug, Clone)]
pub struct Rewrite {
pub scope: RekeyScope,
/// Every member the new key must reach.
pub recipients: Vec<PublicKey>,
/// The members it must not reach: whoever the rotation cuts off.
pub excluded: Vec<PublicKey>,
/// The rotator's claim to rank, when it holds the grant to cite.
pub citation: Option<AuthorityCitation>,
}
impl Rewrite {
/// Whether `rotator` may cut `excluded` off at all.
pub fn authorized(
&self,
roles: &CommunityRoles,
owner: &PublicKey,
rotator: &PublicKey,
) -> bool {
permissions(self.scope)
.iter()
.any(|bits| cord06::rekey_authorized(roles, owner, rotator, *bits, &self.excluded))
}
}
/// Publish this client's own rotation of one scope to its next epoch.
#[allow(clippy::too_many_arguments)]
pub async fn rotate<S>(
client: &Client,
state: &CommunityState,
roles: &CommunityRoles,
signer: &S,
me: PublicKey,
rewrite: &Rewrite,
at: Timestamp,
) -> Result<Epoch>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
if rewrite.excluded.contains(&me) {
bail!("a rotation cannot cut off the member who publishes it");
}
if !rewrite.recipients.contains(&me) {
bail!("a rotation must deliver its own rotator");
}
if rewrite
.excluded
.iter()
.any(|target| rewrite.recipients.contains(target))
{
bail!("a rotation cannot both deliver to and cut off the same member");
}
let (held_epoch, held_key) = stepping_off(state, rewrite.scope)?;
let epoch = Epoch(held_epoch.0 + 1);
if epoch.0 > cord06::MAX_REKEY_EPOCH {
bail!("epoch {} is past the rekey ceiling", epoch.0);
}
let plan = cord06::plan_rotation(rewrite.scope, epoch)?;
let new_key = plan.new_key();
let control_pk = match &plan {
RotationPlan::Base(refounding) => Some(refounding.signer(&state.id)?.pk().to_bytes()),
RotationPlan::Channel { .. } => None,
};
let mut blobs = Vec::with_capacity(rewrite.recipients.len());
for recipient in &rewrite.recipients {
// A refounded Control Plane's root reaches staff only.
let control_root = match &plan {
RotationPlan::Base(refounding) => roles
.is_staff(recipient, &state.owner)
.then_some(refounding.new_control_root),
RotationPlan::Channel { .. } => None,
};
blobs.push(
cord06::build_blob(
signer,
recipient,
rewrite.scope,
epoch,
&new_key,
control_pk.as_ref(),
control_root.as_ref(),
)
.await?,
);
}
let group = cord06::rekey_group(rewrite.scope, &state.community_root, &state.id, epoch)?;
// A rotation that cuts somebody off is the severed kind.
let severed = !rewrite.excluded.is_empty();
let mut wraps = cord06::build_rekey_chunks(
signer,
&group,
rewrite.scope,
epoch,
held_epoch,
&epoch_key_commitment(held_epoch, &held_key),
&blobs,
rewrite.citation.as_ref(),
severed,
at.as_secs(),
)
.await?;
if let RotationPlan::Base(refounding) = &plan {
let carried = carry_heads(client, state, refounding, at).await?;
if carried.is_empty() && !state.heads.is_empty() {
log::warn!("community: a refounding carried no settled head forward");
}
wraps.extend(carried);
}
sync::publish_wraps(client, &wraps, &state.relays).await;
log::debug!(
"community: rotated epoch {} to {} for {} member(s)",
held_epoch.0,
epoch.0,
blobs.len()
);
Ok(epoch)
}
/// The epoch and key a rotation steps off.
fn stepping_off(state: &CommunityState, scope: RekeyScope) -> Result<(Epoch, [u8; 32])> {
match scope {
RekeyScope::Base => Ok((state.root_epoch, state.community_root)),
RekeyScope::Channel(channel) => state
.channels
.iter()
.find(|held| held.id == channel)
.and_then(|held| held.current())
.ok_or_else(|| anyhow::anyhow!("no current key is held for {}", channel.to_hex())),
}
}
/// Re-seal the settled control heads under a refounding's new groups.
///
/// A rotation only mints keys. Unless the heads ride across with it, the new
/// epoch's Control Plane starts empty, and a member whose material never carried
/// the root we are stepping off folds no roles, metadata or banlist at all.
async fn carry_heads(
client: &Client,
state: &CommunityState,
refounding: &Refounding,
at: Timestamp,
) -> Result<Vec<Event>> {
if state.heads.is_empty() {
return Ok(Vec::new());
}
let planes = sync::planes(state)?;
let authors: BTreeSet<PublicKey> = planes
.iter()
.filter(|plane| matches!(plane.kind, PlaneKind::Control(_)))
.map(|plane| plane.address)
.collect();
if authors.is_empty() {
return Ok(Vec::new());
}
let wraps = client
.database()
.query(
Filter::new()
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
.authors(authors),
)
.await?;
let mut seals = Vec::new();
let mut seen: BTreeSet<EventId> = BTreeSet::new();
for wrap in &wraps {
let Some(plane) = planes.iter().find(|plane| {
matches!(plane.kind, PlaneKind::Control(_)) && plane.address == wrap.pubkey
}) else {
continue;
};
// A retired root reads only what was sealed before its rotation.
if !plane.accepts(wrap) {
continue;
}
let Ok(opened) =
cord01::open_wrap_at(wrap, &plane.address, plane.group.conversation(), true)
else {
continue;
};
// Only a plaintext seal can be carried: `compact` re-signs nothing and
// re-wraps the edition the author already sealed.
if opened.seal_form != SealForm::Plaintext {
continue;
}
let Ok(edition) = cord04::parse_edition(&opened.rumor) else {
continue;
};
let settled = state.heads.iter().any(|head| {
head.entity == edition.entity
&& head.version == edition.version
&& head.self_hash == edition.self_hash
});
// The same edition can be reachable twice once a head has been carried
// forward before: a head is published once per refounding.
if settled && seen.insert(opened.seal.id) {
seals.push(opened.seal);
}
}
if seals.is_empty() {
return Ok(Vec::new());
}
Ok(cord06::compact(
&seals,
&refounding.read(&state.id)?,
&refounding.signer(&state.id)?,
at.as_secs(),
)?)
}
/// The key grouping a rotation's chunks, recomputed from a collected rotation.
fn rotation_key(rotation: &Rotation) -> RotationKey {
(
rotation.rotator.to_bytes(),
rotation.scope.id32(),
rotation.new_epoch.0,
rotation.prev_commit,
)
}
/// What one scope's walk found.
#[derive(Debug, Default)]
struct Step {
adopted: Option<Adopted>,
removed_at: Option<Epoch>,
stranded: bool,
/// The rotators of every base rotation this walk verified, which is who may
/// seed the Guestbook snapshot of an epoch they minted.
refounders: BTreeSet<PublicKey>,
}
#[derive(Debug, Clone)]
struct Adopted {
epoch: Epoch,
key: [u8; 32],
control_pk: Option<PublicKey>,
control_root: Option<[u8; 32]>,
stepped: Vec<HeldKey>,
}
/// What one rotation offered this client, and when it was published.
#[derive(Debug, Clone, Copy)]
struct Delivery {
key: [u8; 32],
control_pk: Option<PublicKey>,
control_root: Option<[u8; 32]>,
at_ms: u64,
}
/// Walk a scope's rotations forward, one epoch at a time, off the key held.
#[allow(clippy::too_many_arguments)]
async fn walk<S>(
scope: RekeyScope,
permissions: &[u64],
mut held_epoch: Epoch,
mut held_key: [u8; 32],
state: &CommunityState,
roles: &CommunityRoles,
signer: &S,
me: PublicKey,
rotations: &[Rotation],
published: &BTreeMap<RotationKey, u64>,
) -> Result<Step>
where
S: AsyncNip44 + ?Sized,
{
let mut step = Step::default();
let mut stepped: Vec<HeldKey> = Vec::new();
let ceiling = held_epoch.0 + REKEY_LOOKAHEAD;
loop {
let target = Epoch(held_epoch.0 + 1);
if target.0 > ceiling {
break;
}
let candidates: Vec<&Rotation> = rotations
.iter()
.filter(|rotation| {
rotation.scope == scope
&& rotation.new_epoch == target
&& rotation.is_complete()
&& !state.banned.contains(&rotation.rotator)
&& permissions
.iter()
.any(|bits| roles.is_authorized(&rotation.rotator, &state.owner, *bits))
})
.collect();
if candidates.is_empty() {
break;
}
let mut delivery: Option<Delivery> = None;
let mut addressed = false;
for rotation in candidates
.iter()
.filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends)
{
// Who minted an epoch is proven by the rotation itself, not by a blob:
// a rotation is only a candidate after continuity against a key we
// hold, so its rotator minted this epoch whether or not it addressed
// us. A member who joined on a stale bundle never held the epochs
// between, and the snapshot that seeds them is only honored on this
// npub's authority (CORD-02 §5).
if scope == RekeyScope::Base {
step.refounders.insert(rotation.rotator);
}
let at_ms = published
.get(&rotation_key(rotation))
.copied()
.unwrap_or_default();
let blobs = cord06::find_my_blobs(
&rotation.blobs,
&rotation.rotator,
&me,
scope,
rotation.new_epoch,
)
.collect::<Vec<_>>();
if blobs.is_empty() {
continue;
}
addressed = true;
for blob in blobs {
let Ok(delivered) = cord06::open_blob(
signer,
&rotation.rotator,
scope,
rotation.new_epoch,
blob,
&state.id,
)
.await
else {
continue;
};
let control_pk = delivered
.control_pk
.and_then(|bytes| PublicKey::from_slice(&bytes).ok());
let raced = delivery.map(|held| held.key);
// Racing rotations converge on the lowest new key.
if raced.is_none_or(|raced| delivered.new_key < raced) {
delivery = Some(Delivery {
key: delivered.new_key,
control_pk,
control_root: delivered.control_root,
at_ms,
});
} else if let Some(held) = delivery.as_mut() {
held.at_ms = held.at_ms.min(at_ms);
}
}
}
if let Some(delivered) = delivery {
stepped.insert(
0,
HeldKey {
epoch: held_epoch,
key: held_key,
retired_at: Some(Timestamp::from_secs(delivered.at_ms / 1000)),
},
);
held_epoch = target;
held_key = delivered.key;
step.adopted = Some(Adopted {
epoch: target,
key: delivered.key,
control_pk: delivered.control_pk,
control_root: delivered.control_root,
stepped: stepped.clone(),
});
continue;
}
if addressed {
break;
}
let judged: Vec<&&Rotation> = candidates
.iter()
.filter(|rotation| rotation.continuity(held_epoch, &held_key) != Continuity::Fork)
.collect();
let published_at = |rotation: &&Rotation| {
published
.get(&rotation_key(rotation))
.copied()
.unwrap_or_default()
};
if judged.iter().any(|rotation| {
published_at(rotation) >= state.added_at_ms
&& permissions.iter().any(|bits| {
roles.can_act_on_member(&rotation.rotator, &state.owner, &me, *bits)
})
}) {
step.removed_at = Some(target);
} else if scope == RekeyScope::Base
&& judged
.iter()
.any(|rotation| published_at(rotation) < state.added_at_ms)
{
step.stranded = true;
}
break;
}
Ok(step)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use concord::cord04::roles::CommunityRoles;
use concord::cord06::{RekeyBlob, build_blob, build_rekey_chunks};
use concord::derive::{
base_rekey_group_key, channel_rekey_group_key, control_signer_group_key,
epoch_key_commitment,
};
use concord::state::{ChannelKeyRef, HeldRoot};
use nostr_memory::MemoryDatabase;
use state::UniversalSigner;
use super::*;
const AT_MS: u64 = 1_700_000_000_000;
const ROOT: [u8; 32] = [0x55; 32];
const NEW_ROOT: [u8; 32] = [0x66; 32];
const NEWER_ROOT: [u8; 32] = [0x77; 32];
const CHANNEL_KEY: [u8; 32] = [0x07; 32];
const NEW_CHANNEL_KEY: [u8; 32] = [0x08; 32];
fn client() -> Client {
ClientBuilder::default()
.database(MemoryDatabase::unbounded())
.build()
}
fn state(owner: PublicKey, id: CommunityId, channel: ChannelId) -> CommunityState {
CommunityState {
id,
name: Some("Room".to_owned()),
owner,
owner_salt: [0x01; 32],
community_root: ROOT,
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::from([(0, owner)]),
channels: vec![ChannelKeyRef {
id: channel,
name: "staff".to_owned(),
private: true,
epoch: Epoch(0),
key: Some(CHANNEL_KEY),
priors: Vec::new(),
}],
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
refounders: BTreeSet::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: AT_MS,
}
}
async fn store(client: &Client, wraps: &[Event]) {
for wrap in wraps {
client.database().save_event(wrap).await.expect("saves");
}
}
fn base_chunks(
owner: &Keys,
id: &CommunityId,
prior_commit: &[u8; 32],
blobs: &[RekeyBlob],
) -> Vec<Event> {
let group = base_rekey_group_key(&ROOT, id, Epoch(1)).expect("derives");
smol::block_on(build_rekey_chunks(
owner,
&group,
RekeyScope::Base,
Epoch(1),
Epoch(0),
prior_commit,
blobs,
None,
false,
AT_MS / 1000,
))
.expect("builds")
}
fn channel_chunks(
owner: &Keys,
channel: &ChannelId,
prior_commit: &[u8; 32],
blobs: &[RekeyBlob],
) -> Vec<Event> {
let scope = RekeyScope::Channel(*channel);
let group = channel_rekey_group_key(&ROOT, channel, Epoch(1)).expect("derives");
smol::block_on(build_rekey_chunks(
owner,
&group,
scope,
Epoch(1),
Epoch(0),
prior_commit,
blobs,
None,
false,
AT_MS / 1000,
))
.expect("builds")
}
fn blob_for(
rotator: &Keys,
recipient: &Keys,
scope: RekeyScope,
key: [u8; 32],
control_pk: Option<&[u8; 32]>,
) -> RekeyBlob {
smol::block_on(build_blob(
rotator,
&recipient.public_key(),
scope,
Epoch(1),
&key,
control_pk,
None,
))
.expect("builds")
}
#[test]
fn a_complete_base_rotation_is_adopted_and_retires_the_prior_root() {
smol::block_on(async {
let client = client();
let owner = Keys::generate();
let me = Keys::generate();
let id = CommunityId::from_bytes([0x42; 32]);
let channel = ChannelId::from_bytes([0x9c; 32]);
let state = state(owner.public_key(), id, channel);
let control_root = [0xAB; 32];
let control_pk = control_signer_group_key(&control_root, &id, Epoch(1))
.expect("derives")
.pk()
.to_bytes();
let blob = blob_for(&owner, &me, RekeyScope::Base, NEW_ROOT, Some(&control_pk));
let wraps = base_chunks(&owner, &id, &epoch_key_commitment(Epoch(0), &ROOT), &[blob]);
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let roles = CommunityRoles::default();
let adoptions = adopt(&client, &state, &roles, &signer, me.public_key())
.await
.expect("reads");
let base = adoptions.base.expect("adopted");
assert_eq!(base.epoch, Epoch(1));
assert_eq!(base.key, NEW_ROOT);
assert_eq!(base.control_pk, PublicKey::from_slice(&control_pk).ok());
assert_eq!(base.stepped.len(), 1);
assert_eq!(base.stepped[0].epoch, Epoch(0));
assert_eq!(base.stepped[0].key, ROOT);
assert_eq!(
base.stepped[0].retired_at,
Some(Timestamp::from_secs(AT_MS / 1000))
);
assert!(adoptions.removed_at.is_none());
assert!(!adoptions.stranded);
});
}
/// A complete base rotation off a retained root is adopted from it, which
/// is the only way its epoch's refounder is ever learned: a Guestbook
/// snapshot seeds members on that npub's authority alone (CORD-02 §5).
#[test]
fn a_rotation_past_a_retained_root_is_adopted_and_names_its_refounder() {
smol::block_on(async {
let client = client();
let owner = Keys::generate();
let me = Keys::generate();
let id = CommunityId::from_bytes([0x42; 32]);
let channel = ChannelId::from_bytes([0x9c; 32]);
// At epoch 1, with the root it stepped off retained.
let mut state = state(owner.public_key(), id, channel);
state.root_epoch = Epoch(1);
state.community_root = NEW_ROOT;
state.held_roots = vec![HeldRoot {
epoch: Epoch(0),
key: ROOT,
control_pk: None,
retired_at: None,
}];
let blob = smol::block_on(build_blob(
&owner,
&me.public_key(),
RekeyScope::Base,
Epoch(2),
&NEWER_ROOT,
None,
None,
))
.expect("builds");
let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives");
let wraps = smol::block_on(build_rekey_chunks(
&owner,
&group,
RekeyScope::Base,
Epoch(2),
Epoch(1),
&epoch_key_commitment(Epoch(1), &NEW_ROOT),
&[blob],
None,
false,
AT_MS / 1000,
))
.expect("builds");
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let adoptions = adopt(
&client,
&state,
&CommunityRoles::default(),
&signer,
me.public_key(),
)
.await
.expect("reads");
let base = adoptions.base.expect("adopted");
assert_eq!(base.epoch, Epoch(2));
assert_eq!(base.key, NEWER_ROOT);
assert_eq!(base.stepped.len(), 1, "the root it stepped off");
assert_eq!(base.stepped[0].epoch, Epoch(1));
assert!(adoptions.refounders.contains(&owner.public_key()));
});
}
/// A rotation that addresses somebody else still names the npub who minted the
/// epoch: continuity against a key we hold is what proves it. That authority is
/// what a Guestbook snapshot is honored on, so a member who joined on a stale
/// bundle can still read the roster the Refounding seeded.
#[test]
fn a_rotation_that_delivered_us_no_key_still_names_its_refounder() {
smol::block_on(async {
let client = client();
let owner = Keys::generate();
let me = Keys::generate();
let other = Keys::generate();
let id = CommunityId::from_bytes([0x42; 32]);
let channel = ChannelId::from_bytes([0x9c; 32]);
// At epoch 2, holding the root the rotation stepped off but never
// having walked it: the shape a stale join bundle produces.
let mut state = state(owner.public_key(), id, channel);
state.root_epoch = Epoch(2);
state.community_root = NEWER_ROOT;
state.held_roots = vec![HeldRoot {
epoch: Epoch(1),
key: NEW_ROOT,
control_pk: None,
retired_at: None,
}];
let blob = smol::block_on(build_blob(
&owner,
&other.public_key(),
RekeyScope::Base,
Epoch(2),
&NEWER_ROOT,
None,
None,
))
.expect("builds");
let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives");
let wraps = smol::block_on(build_rekey_chunks(
&owner,
&group,
RekeyScope::Base,
Epoch(2),
Epoch(1),
&epoch_key_commitment(Epoch(1), &NEW_ROOT),
&[blob],
None,
false,
AT_MS / 1000,
))
.expect("builds");
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let adoptions = adopt(
&client,
&state,
&CommunityRoles::default(),
&signer,
me.public_key(),
)
.await
.expect("reads");
assert!(adoptions.base.is_none(), "nothing to adopt");
assert!(!adoptions.is_empty(), "the minter is still learned");
assert!(adoptions.refounders.contains(&owner.public_key()));
});
}
/// A rotation off a key we do not hold is a fork: adoptable by nobody here.
#[test]
fn a_rotation_that_does_not_extend_the_held_key_is_never_adopted() {
smol::block_on(async {
let client = client();
let owner = Keys::generate();
let me = Keys::generate();
let id = CommunityId::from_bytes([0x42; 32]);
let channel = ChannelId::from_bytes([0x9c; 32]);
let state = state(owner.public_key(), id, channel);
let blob = blob_for(&owner, &me, RekeyScope::Base, NEW_ROOT, None);
let wraps = base_chunks(
&owner,
&id,
&epoch_key_commitment(Epoch(0), &[0x99; 32]),
&[blob],
);
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let roles = CommunityRoles::default();
let adoptions = adopt(&client, &state, &roles, &signer, me.public_key())
.await
.expect("reads");
assert!(adoptions.is_empty());
});
}
/// Every chunk held, none carrying my blob, from a rotator who outranks me
/// and published after I joined: I was excluded, not stranded.
#[test]
fn a_blobless_rotation_from_an_outranking_rotator_removes_the_member() {
smol::block_on(async {
let client = client();
let owner = Keys::generate();
let me = Keys::generate();
let other = Keys::generate();
let id = CommunityId::from_bytes([0x42; 32]);
let channel = ChannelId::from_bytes([0x9c; 32]);
let state = state(owner.public_key(), id, channel);
let blob = blob_for(&owner, &other, RekeyScope::Base, NEW_ROOT, None);
let wraps = base_chunks(&owner, &id, &epoch_key_commitment(Epoch(0), &ROOT), &[blob]);
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let roles = CommunityRoles::default();
let adoptions = adopt(&client, &state, &roles, &signer, me.public_key())
.await
.expect("reads");
assert!(adoptions.base.is_none());
assert_eq!(adoptions.removed_at, Some(Epoch(1)));
assert!(!adoptions.stranded);
});
}
#[test]
fn a_channel_rotation_replaces_the_key_and_keeps_the_prior() {
smol::block_on(async {
let client = client();
let owner = Keys::generate();
let me = Keys::generate();
let id = CommunityId::from_bytes([0x42; 32]);
let channel = ChannelId::from_bytes([0x9c; 32]);
let state = state(owner.public_key(), id, channel);
let blob = blob_for(
&owner,
&me,
RekeyScope::Channel(channel),
NEW_CHANNEL_KEY,
None,
);
let wraps = channel_chunks(
&owner,
&channel,
&epoch_key_commitment(Epoch(0), &CHANNEL_KEY),
&[blob],
);
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let roles = CommunityRoles::default();
let adoptions = adopt(&client, &state, &roles, &signer, me.public_key())
.await
.expect("reads");
assert_eq!(adoptions.channels.len(), 1);
let adopted = &adoptions.channels[0];
assert_eq!(adopted.channel, channel);
assert_eq!(adopted.epoch, Epoch(1));
assert_eq!(adopted.key, NEW_CHANNEL_KEY);
assert_eq!(adopted.stepped.len(), 1);
assert_eq!(adopted.stepped[0].epoch, Epoch(0));
assert_eq!(adopted.stepped[0].key, CHANNEL_KEY);
assert_eq!(
adopted.stepped[0].retired_at,
Some(Timestamp::from_secs(AT_MS / 1000))
);
assert!(adoptions.cuts.is_empty());
});
}
}