use super::cipher;
use super::roster::DissolvedEdition;
use super::transport::Transport;
use super::{Community, CommunityId};
use crate::state::SessionGuard;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::{LazyLock, Mutex as StdMutex};
static DRIVE_INFLIGHT: LazyLock<StdMutex<HashSet<String>>> =
LazyLock::new(|| StdMutex::new(HashSet::new()));
pub fn clear_drive_inflight() {
DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
struct DriveClaim(String, SessionGuard);
impl DriveClaim {
fn take(cid: &str) -> Option<Self> {
let mut inflight = DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
if !inflight.insert(cid.to_string()) {
return None;
}
Some(DriveClaim(cid.to_string(), SessionGuard::capture()))
}
}
impl Drop for DriveClaim {
fn drop(&mut self) {
if self.1.is_valid() {
DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
}
}
}
#[cfg(test)]
pub fn test_hold_drive_claim(cid: &str) {
DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).insert(cid.to_string());
}
#[cfg(test)]
pub fn test_release_drive_claim(cid: &str) {
DRIVE_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).remove(cid);
}
pub const MIGRATION_UNLOCK_AT: u64 = 1_785_801_600;
pub const MAX_PAYLOAD_CONTENT: usize = 100_000;
pub const MAX_M_B64: usize = 90_000;
pub const MAX_SIGNPOST_NAME: usize = 120;
pub const MAX_WIRE_EVENT: usize = 60_000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationSignpost {
pub v2_community_id: String,
pub owner_xonly: String,
pub owner_salt: String,
#[serde(default)]
pub relays: Vec<String>,
pub name: String,
pub primary_channel: String,
#[serde(default)]
pub root_epoch: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationPayload {
pub signpost: MigrationSignpost,
pub m: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct WirePayload {
migrated_to: MigrationSignpost,
#[serde(default, skip_serializing_if = "Option::is_none")]
m: Option<String>,
}
fn is_hex64(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn parse_migration_payload(content: &str) -> Option<MigrationPayload> {
if content.len() > MAX_PAYLOAD_CONTENT {
return None;
}
let wire: WirePayload = serde_json::from_str(content).ok()?;
let mut sp = wire.migrated_to;
if !is_hex64(&sp.v2_community_id)
|| !is_hex64(&sp.owner_xonly)
|| !is_hex64(&sp.owner_salt)
|| !is_hex64(&sp.primary_channel)
{
return None;
}
sp.v2_community_id = sp.v2_community_id.to_lowercase();
sp.owner_xonly = sp.owner_xonly.to_lowercase();
sp.owner_salt = sp.owner_salt.to_lowercase();
sp.primary_channel = sp.primary_channel.to_lowercase();
sp.relays = super::cap_relays(sp.relays);
if sp.name.chars().count() > MAX_SIGNPOST_NAME {
sp.name = sp.name.chars().take(MAX_SIGNPOST_NAME).collect();
}
let m = match wire.m {
Some(m) if m.len() > MAX_M_B64 => return None,
other => other,
};
Some(MigrationPayload { signpost: sp, m })
}
pub fn build_migration_content(signpost: &MigrationSignpost, m: Option<String>) -> Result<String, String> {
serde_json::to_string(&WirePayload { migrated_to: signpost.clone(), m })
.map_err(|e| format!("serialize migration payload: {e}"))
}
pub fn seal_m(server_root: &[u8; 32], join_material_json: &[u8]) -> Result<String, String> {
cipher::seal(server_root, join_material_json)
}
pub fn open_m(held_roots: &[(u64, [u8; 32])], m_b64: &str) -> Option<Vec<u8>> {
if m_b64.len() > MAX_M_B64 {
return None;
}
let mut roots: Vec<&(u64, [u8; 32])> = held_roots.iter().collect();
roots.sort_by(|a, b| b.0.cmp(&a.0));
for (_, key) in roots {
if let Ok(plain) = cipher::open(key, m_b64) {
return Some(plain);
}
}
None
}
pub fn select_pointer(editions: &[DissolvedEdition], owner_hex: &str) -> Option<(MigrationPayload, String)> {
let mut best: Option<(&DissolvedEdition, MigrationPayload)> = None;
for e in editions {
if e.author.to_hex() != owner_hex {
continue;
}
let Some(payload) = parse_migration_payload(&e.content) else { continue };
best = match best {
Some((cur, cur_p))
if (cur.created_at, std::cmp::Reverse(cur.inner_id))
>= (e.created_at, std::cmp::Reverse(e.inner_id)) =>
{
Some((cur, cur_p))
}
_ => Some((e, payload)),
};
}
best.map(|(e, p)| (p, e.content.clone()))
}
pub fn check_outer_size(outer: &nostr_sdk::prelude::Event) -> Result<(), String> {
let len = outer.as_json().len();
if len > MAX_WIRE_EVENT {
return Err(format!(
"migration event is {len} bytes, over the {MAX_WIRE_EVENT}-byte relay ceiling; \
this community is too large for a single migration event"
));
}
Ok(())
}
pub fn catchup_exempt(community_id: &str, target_epoch: u64) -> bool {
if crate::db::community::get_migrated_to(community_id).ok().flatten().is_some() {
return false; }
let Ok(Some(raw)) = crate::db::community::get_migration_pointer(community_id) else {
return false; };
let Some(payload) = parse_migration_payload(&raw) else { return false };
target_epoch <= payload.signpost.root_epoch
}
fn held_roots(community_id: &str) -> Vec<(u64, [u8; 32])> {
crate::db::community::held_epoch_keys(community_id, crate::community::SERVER_ROOT_SCOPE_HEX)
.unwrap_or_default()
.into_iter()
.map(|(e, k)| (e.0, k))
.collect()
}
pub async fn drive_migration<T: Transport + ?Sized>(
transport: &T,
community: &Community,
) -> Result<Option<String>, String> {
let session = SessionGuard::capture();
let cid = community.id.to_hex();
let Some(_claim) = DriveClaim::take(&cid) else {
return Ok(None);
};
if crate::db::community::get_migrated_to(&cid).ok().flatten().is_some() {
return Ok(None);
}
let Some(raw) = crate::db::community::get_migration_pointer(&cid)? else {
return Ok(None); };
let Some(payload) = parse_migration_payload(&raw) else {
let _ = crate::db::community::set_migration_checked(&cid);
return Ok(None);
};
let Some(owner) = super::service::proven_owner_hex(community) else { return Ok(None) };
if payload.signpost.owner_xonly != owner {
return Ok(None); }
if !super::v2::derive::verify_community_id(
&CommunityId(crate::simd::hex::hex_to_bytes_32(&payload.signpost.v2_community_id)),
&crate::simd::hex::hex_to_bytes_32(&payload.signpost.owner_xonly),
&crate::simd::hex::hex_to_bytes_32(&payload.signpost.owner_salt),
) {
return Ok(None); }
let Some(m_b64) = payload.m.as_deref() else {
return Ok(None); };
let mut plain = open_m(&held_roots(&cid), m_b64);
if plain.is_none() && community.server_root_epoch.0 < payload.signpost.root_epoch {
let _ = super::service::catch_up_server_root(transport, community).await;
if !session.is_valid() {
return Err("account changed during migration catch-up".to_string());
}
plain = open_m(&held_roots(&cid), m_b64);
}
let Some(plain) = plain else {
return Ok(None); };
let jm: super::v2::list::JoinMaterial =
serde_json::from_slice(&plain).map_err(|e| format!("migration join material parse: {e}"))?;
if jm.community_id != payload.signpost.v2_community_id || jm.owner != payload.signpost.owner_xonly {
return Err("migration payload keys disagree with the signpost".to_string());
}
let v2_id = CommunityId(crate::simd::hex::hex_to_bytes_32(&payload.signpost.v2_community_id));
let v2_hex = payload.signpost.v2_community_id.clone();
if crate::db::community::load_community_v2(&v2_id)?.is_none() {
let v2 = super::v2::service::accept_migration_material(transport, &jm).await?;
if crate::simd::hex::bytes_to_hex_32(&v2.identity.community_id.0) != v2_hex {
return Err("joined community id disagrees with the migration pointer".to_string());
}
if !session.is_valid() {
return Err("account changed during migration join".to_string());
}
}
let flock = super::v2::realtime::follow_lock(&v2_id);
let _fguard = flock.lock().await;
if !session.is_valid() {
return Err("account changed during migration flip".to_string());
}
crate::db::community::reparent_channels_and_fence(&cid, &v2_hex)?;
Ok(Some(v2_hex))
}
pub const PHASE_TWIN_MINTED: i64 = 1;
pub const PHASE_TWIN_BUILT: i64 = 2;
pub const PHASE_TWIN_REFOUNDED: i64 = 3;
pub const PHASE_CARRIER_PUBLISHED: i64 = 4;
pub const PHASE_FLIPPED: i64 = 5;
fn v1_snapshot_members(v1_cid: &str, _owner_hex: &str) -> Vec<nostr_sdk::prelude::PublicKey> {
crate::db::community::community_member_activity_capped(v1_cid, false)
.unwrap_or_default()
.iter()
.filter_map(|(npub, _)| nostr_sdk::prelude::PublicKey::parse(npub).ok())
.collect()
}
pub fn wizard_unlocked(now_secs: u64) -> bool {
now_secs >= MIGRATION_UNLOCK_AT
}
pub async fn gate_fresh_v1_join<T: Transport + ?Sized>(
transport: &T,
community: &Community,
now_secs: u64,
) -> Result<(), String> {
if now_secs < MIGRATION_UNLOCK_AT {
return Ok(());
}
if matches!(crate::db::community::load_community(&community.id), Ok(Some(_))) {
return Ok(());
}
if let Some(owner) = super::service::proven_owner_hex(community) {
let records = super::service::dissolved_tombstone_records(transport, community).await;
if select_pointer(&records, &owner).is_some() {
return Ok(());
}
}
Err("This community still uses the legacy protocol and can no longer be joined. Ask the owner to upgrade it to Concord v2 and share a fresh invite.".to_string())
}
pub fn migration_state(
migrated: bool,
ledger_phase: i64,
dissolved: bool,
is_owner: bool,
unlocked: bool,
) -> &'static str {
if migrated {
"migrated"
} else if ledger_phase > 0 && is_owner {
"in_progress"
} else if dissolved {
"dissolved"
} else if !is_owner {
"not_owner"
} else if unlocked {
"ready"
} else {
"locked"
}
}
pub fn migration_eligible(migrated: bool, ledger_phase: i64, dissolved: bool, is_owner: bool) -> bool {
is_owner && !migrated && (!dissolved || ledger_phase > 0)
}
fn emit_migration_progress(label: &str, pct: u8) {
crate::emit_event("community_migration_progress", &serde_json::json!({ "label": label, "pct": pct }));
}
pub async fn migrate_community_to_v2<T: Transport + ?Sized>(
transport: &T,
v1: &Community,
now_secs: u64,
) -> Result<String, String> {
let session = SessionGuard::capture();
let v1_cid = v1.id.to_hex();
let Some(_claim) = DriveClaim::take(&v1_cid) else {
return Err("this community's upgrade is already in progress".to_string());
};
emit_migration_progress("Preparing the upgrade...", 5);
if !wizard_unlocked(now_secs) {
return Err("community migration is not unlocked yet".to_string());
}
if !super::service::is_proven_owner(v1) {
return Err("only the community owner can migrate the community".to_string());
}
if let Some(v2) = crate::db::community::get_migrated_to(&v1_cid).ok().flatten() {
if let Some((ledger_v2, phase, _)) = crate::db::community::get_migration_ledger(&v1_cid).ok().flatten() {
if ledger_v2 == v2 && phase < PHASE_FLIPPED {
let _ = crate::db::community::set_migration_ledger(&v1_cid, &v2, PHASE_FLIPPED, "");
return Ok(v2);
}
}
return Err("this community has already been migrated".to_string());
}
let ledger = crate::db::community::get_migration_ledger(&v1_cid).ok().flatten();
let resume_phase = ledger.as_ref().map(|(_, p, _)| *p).unwrap_or(0);
if resume_phase == 0 && crate::db::community::get_community_dissolved(&v1_cid).unwrap_or(false) {
return Err("this community has been dissolved; it cannot be migrated".to_string());
}
let Some(owner_hex) = super::service::proven_owner_hex(v1) else {
return Err("cannot resolve the community owner".to_string());
};
emit_migration_progress("Creating the new community...", 15);
let twin = if resume_phase >= PHASE_TWIN_MINTED {
let (v2_hex, _, _) = ledger.as_ref().unwrap();
crate::db::community::load_community_v2(&CommunityId(crate::simd::hex::hex_to_bytes_32(v2_hex)))?
.ok_or("migration twin missing on resume")?
} else {
let primary = v1.channels.first().ok_or("v1 community has no channels")?;
let twin = super::v2::service::create_migration_twin(
transport,
&v1.name,
v1.relays.clone(),
v1.description.clone(),
(primary.id, primary.name.clone()),
)
.await?;
let v2_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
if !session.is_valid() {
return Err("account changed during twin mint".to_string());
}
crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_TWIN_MINTED, "")?;
twin
};
let v2_hex = crate::simd::hex::bytes_to_hex_32(&twin.identity.community_id.0);
if resume_phase < PHASE_TWIN_BUILT {
emit_migration_progress("Copying channels, roles and bans...", 35);
for ch in v1.channels.iter().skip(1) {
super::v2::service::create_public_channel_with_id(transport, &twin, &ch.name, ch.id).await?;
}
let banlist = crate::db::community::get_community_banlist(&v1_cid).unwrap_or_default();
super::v2::service::clone_banlist_to_twin(transport, &twin, &banlist).await?;
if !session.is_valid() {
return Err("account changed during banlist clone".to_string());
}
let v1_roles = crate::db::community::get_community_roles(&v1_cid).unwrap_or_default();
super::v2::service::clone_governance_to_twin(transport, &twin, &v1_roles, &banlist).await?;
if !session.is_valid() {
return Err("account changed during twin build".to_string());
}
crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_TWIN_BUILT, "")?;
}
if resume_phase < PHASE_TWIN_REFOUNDED {
emit_migration_progress("Securing member access...", 55);
let members = v1_snapshot_members(&v1_cid, &owner_hex);
super::v2::service::refound_at_birth(transport, &twin, &members).await?;
if !session.is_valid() {
return Err("account changed during birth refound".to_string());
}
crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_TWIN_REFOUNDED, "")?;
}
let twin = crate::db::community::load_community_v2(&twin.identity.community_id)?
.ok_or("migration twin missing after birth refound")?;
if resume_phase < PHASE_CARRIER_PUBLISHED {
emit_migration_progress("Publishing the upgrade for all members...", 75);
let jm = super::v2::service::twin_join_material(&twin);
let m = seal_m(
v1.server_root_key.as_bytes(),
&serde_json::to_vec(&jm).map_err(|e| e.to_string())?,
)?;
let signpost = MigrationSignpost {
v2_community_id: v2_hex.clone(),
owner_xonly: owner_hex.clone(),
owner_salt: crate::simd::hex::bytes_to_hex_32(&twin.identity.owner_salt),
relays: twin.relays.clone(),
name: v1.name.clone(),
primary_channel: v1.channels.first().map(|c| c.id.to_hex()).unwrap_or_default(),
root_epoch: v1.server_root_epoch.0,
};
let content = build_migration_content(&signpost, Some(m))?;
super::service::publish_migration_carrier(transport, v1, &content).await?;
if !session.is_valid() {
return Err("account changed during carrier publish".to_string());
}
crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_CARRIER_PUBLISHED, "")?;
}
emit_migration_progress("Switching you over...", 92);
{
let flock = super::v2::realtime::follow_lock(&twin.identity.community_id);
let _fguard = flock.lock().await;
if !session.is_valid() {
return Err("account changed during migration".to_string());
}
crate::db::community::reparent_channels_and_fence(&v1_cid, &v2_hex)?;
crate::db::community::set_migration_ledger(&v1_cid, &v2_hex, PHASE_FLIPPED, "")?;
}
match super::v2::service::republish_community_list(transport, Some(&twin.identity.community_id)).await {
Ok(true) => {}
_ => super::v2::service::republish_community_list_durable(Some(twin.identity.community_id)),
}
spawn_finalize_migration(v1_cid, v2_hex.clone());
Ok(v2_hex)
}
pub fn spawn_finalize_migration(v1_cid: String, v2_hex: String) {
let session = SessionGuard::capture();
tokio::spawn(async move {
let v2_id = CommunityId(crate::simd::hex::hex_to_bytes_32(&v2_hex));
let Ok(Some(twin)) = crate::db::community::load_community_v2(&v2_id) else { return };
if !session.is_valid() {
return;
}
crate::register_v2_chats_inner(&twin, &session).await;
super::v2::realtime::enqueue_follow(&twin.identity.community_id);
if let Some(client) = crate::state::nostr_client() {
super::v2::realtime::refresh_subscription(&client).await;
}
crate::emit_event(
"community_migrated",
&serde_json::json!({ "v1_community_id": v1_cid, "v2_community_id": v2_hex }),
);
});
}
pub async fn run_migration_maintenance<T: Transport + ?Sized>(transport: &T) -> Vec<String> {
let session = SessionGuard::capture();
let mut flipped = Vec::new();
for cid in crate::db::community::migration_flip_candidates().unwrap_or_default() {
if !session.is_valid() {
return flipped;
}
let Ok(Some(community)) = crate::db::community::load_community(&CommunityId(
crate::simd::hex::hex_to_bytes_32(&cid),
)) else {
continue;
};
match drive_migration(transport, &community).await {
Ok(Some(v2)) => {
spawn_finalize_migration(cid, v2.clone());
flipped.push(v2);
}
Ok(None) => {}
Err(e) => crate::log_warn!("migration retry for {cid}: {e}"),
}
}
flipped.extend(sweep_dissolved_for_migration(transport).await);
flipped
}
pub async fn sweep_dissolved_for_migration<T: Transport + ?Sized>(transport: &T) -> Vec<String> {
let session = SessionGuard::capture();
let mut flipped = Vec::new();
let candidates = crate::db::community::migration_sweep_candidates().unwrap_or_default();
for cid in candidates {
if !session.is_valid() {
return flipped;
}
let Ok(Some(community)) = crate::db::community::load_community(&CommunityId(
crate::simd::hex::hex_to_bytes_32(&cid),
)) else {
continue;
};
let Some(owner) = super::service::proven_owner_hex(&community) else {
let _ = crate::db::community::set_migration_checked(&cid);
continue;
};
let records = super::service::dissolved_tombstone_records(transport, &community).await;
if !session.is_valid() {
return flipped;
}
match select_pointer(&records, &owner) {
Some((_, raw)) => {
let _ = crate::db::community::set_migration_pointer(&cid, &raw);
if let Ok(Some(fresh)) = crate::db::community::load_community(&community.id) {
if let Ok(Some(v2)) = drive_migration(transport, &fresh).await {
spawn_finalize_migration(cid.clone(), v2.clone());
flipped.push(v2);
}
}
}
None => {
let owner_sealed = records.iter().any(|d| d.author.to_hex() == owner);
if owner_sealed {
let _ = crate::db::community::set_migration_checked(&cid);
}
}
}
}
flipped
}
#[cfg(test)]
mod tests {
use super::*;
use crate::community::{roster, CommunityId};
use nostr_sdk::prelude::*;
#[test]
fn snapshot_member_set_is_v1_memberlist_minus_banned() {
let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
crate::db::close_database();
crate::db::clear_id_caches();
let acct = Keys::generate().public_key().to_bech32().unwrap();
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
crate::db::set_app_data_dir(tmp.path().to_path_buf());
crate::db::set_current_account(acct.clone()).unwrap();
crate::db::init_database(&acct).unwrap();
let owner = Keys::generate();
let admin = Keys::generate();
let banned = Keys::generate();
let mut c = crate::community::Community::create("HQ", "general", vec![]);
let cid = c.id.to_hex();
{
c.owner_attestation = Some(crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
.finalize(&owner).unwrap().as_json());
}
crate::db::community::save_community(&c).unwrap();
crate::db::community::set_community_banlist(&cid, &[banned.public_key().to_hex()], 1).unwrap();
use crate::community::roles::{CommunityRoles, MemberGrant, Role};
let role = Role::admin("aa".repeat(32));
crate::db::community::set_community_roles(&cid, &CommunityRoles {
roles: vec![role.clone()],
grants: vec![
MemberGrant { member: admin.public_key().to_hex(), role_ids: vec![role.role_id.clone()] },
MemberGrant { member: banned.public_key().to_hex(), role_ids: vec![role.role_id] },
],
}, 1).unwrap();
let members = v1_snapshot_members(&cid, &owner.public_key().to_hex());
let has = |k: &Keys| members.iter().any(|m| *m == k.public_key());
assert!(has(&owner), "owner is always seeded");
assert!(has(&admin), "a roster admin is seeded (re-asserted by v1's memberlist)");
assert!(!has(&banned), "a banned member is never seeded, even with a stale grant");
crate::db::close_database();
}
#[test]
fn drive_claim_is_exclusive_and_releases_on_drop() {
let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
clear_drive_inflight();
let cid = "ab".repeat(32);
let other = "cd".repeat(32);
let first = DriveClaim::take(&cid).expect("a free cid claims");
assert!(DriveClaim::take(&cid).is_none(), "a second claim on the same cid is refused");
let _independent = DriveClaim::take(&other).expect("a different cid claims freely");
drop(first);
let reclaimed = DriveClaim::take(&cid).expect("drop releases the claim for a later drive");
assert!(DriveClaim::take(&other).is_none(), "the other cid is still independently held");
drop(reclaimed);
clear_drive_inflight();
}
#[test]
fn drive_claim_drop_is_generation_aware() {
let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
clear_drive_inflight();
let cid = "ef".repeat(32);
let stale = DriveClaim::take(&cid).expect("old account's drive claims");
crate::state::bump_session_generation();
clear_drive_inflight();
let fresh = DriveClaim::take(&cid).expect("the new account's drive claims the same cid");
drop(stale);
assert!(
DriveClaim::take(&cid).is_none(),
"a stale generation's Drop must not release the current account's claim"
);
drop(fresh);
assert!(DriveClaim::take(&cid).is_some(), "a valid-generation Drop releases");
clear_drive_inflight();
}
#[test]
fn migration_state_ladder_is_priority_ordered() {
assert_eq!(migration_state(true, 0, false, true, true), "migrated");
assert_eq!(migration_state(true, PHASE_CARRIER_PUBLISHED, true, true, true), "migrated");
assert_eq!(
migration_state(false, PHASE_CARRIER_PUBLISHED, true, true, true),
"in_progress",
"an owner mid-migration must be offered Resume even though their carrier sealed v1"
);
assert!(
migration_eligible(false, PHASE_CARRIER_PUBLISHED, true, true),
"the sealed-but-resumable community stays eligible so the command isn't refused"
);
assert_eq!(migration_state(false, 0, true, true, true), "dissolved");
assert!(!migration_eligible(false, 0, true, true), "a plainly dissolved community is not migratable");
assert_eq!(migration_state(false, PHASE_TWIN_MINTED, false, false, true), "not_owner");
assert!(!migration_eligible(false, PHASE_TWIN_MINTED, false, false));
assert_eq!(migration_state(false, 0, false, true, true), "ready");
assert_eq!(migration_state(false, 0, false, true, false), "locked");
assert!(migration_eligible(false, 0, false, true), "eligibility is the ownership+fence question, not the clock");
}
#[test]
fn wizard_timelock_boundary() {
assert!(!wizard_unlocked(0));
assert!(!wizard_unlocked(MIGRATION_UNLOCK_AT - 1));
assert!(wizard_unlocked(MIGRATION_UNLOCK_AT), "unlocks exactly at the boundary");
assert!(wizard_unlocked(MIGRATION_UNLOCK_AT + 86_400));
}
fn signpost() -> MigrationSignpost {
MigrationSignpost {
v2_community_id: "aa".repeat(32),
owner_xonly: "bb".repeat(32),
owner_salt: "cc".repeat(32),
relays: vec!["wss://relay.example.com".into()],
name: "Team Rocket".into(),
primary_channel: "dd".repeat(32),
root_epoch: 3,
}
}
#[test]
fn payload_roundtrip() {
let content = build_migration_content(&signpost(), Some("bTEyMw==".into())).unwrap();
let p = parse_migration_payload(&content).unwrap();
assert_eq!(p.signpost, signpost());
assert_eq!(p.m.as_deref(), Some("bTEyMw=="));
}
#[test]
fn plain_dissolution_is_no_payload() {
assert!(parse_migration_payload("{}").is_none());
assert!(parse_migration_payload("").is_none());
assert!(parse_migration_payload("not json at all").is_none());
}
#[test]
fn bad_hex_rejected() {
for field in ["v2_community_id", "owner_xonly", "owner_salt", "primary_channel"] {
let mut sp = signpost();
match field {
"v2_community_id" => sp.v2_community_id = "zz".repeat(32),
"owner_xonly" => sp.owner_xonly = "short".into(),
"owner_salt" => sp.owner_salt = String::new(),
_ => sp.primary_channel = "gg".repeat(32),
}
let content = build_migration_content(&sp, None).unwrap();
assert!(parse_migration_payload(&content).is_none(), "field {field} accepted");
}
}
#[test]
fn bounds_enforced() {
let content = build_migration_content(&signpost(), Some("A".repeat(MAX_M_B64 + 1))).unwrap();
assert!(parse_migration_payload(&content).is_none());
assert!(parse_migration_payload(&"x".repeat(MAX_PAYLOAD_CONTENT + 1)).is_none());
let mut sp = signpost();
sp.relays = (0..40).map(|i| format!("wss://r{i}.example.com")).collect();
sp.name = "n".repeat(500);
let p = parse_migration_payload(&build_migration_content(&sp, None).unwrap()).unwrap();
assert_eq!(p.signpost.relays.len(), crate::community::MAX_COMMUNITY_RELAYS);
assert_eq!(p.signpost.name.chars().count(), MAX_SIGNPOST_NAME);
}
#[test]
fn m_seal_open_multi_root() {
let old_root = [7u8; 32];
let new_root = [8u8; 32];
let sealed = seal_m(&old_root, b"join material").unwrap();
let held = vec![(1u64, old_root), (2u64, new_root)];
assert_eq!(open_m(&held, &sealed).unwrap(), b"join material");
assert!(open_m(&[(2u64, new_root)], &sealed).is_none());
}
#[test]
fn seal_errors_past_nip44_cap() {
assert!(seal_m(&[1u8; 32], &vec![0u8; 70_000]).is_err());
}
#[test]
fn v040_accepts_extended_tombstone_as_plain_dissolution() {
let owner = Keys::generate();
let cid = CommunityId([0x42u8; 32]);
let m = Some(base64_simd::STANDARD.encode_to_string(vec![0xabu8; 7_500]));
let content = build_migration_content(&signpost(), m).unwrap();
let inner = roster::build_group_dissolved_edition_with_content(&owner, &cid, 1_753_000_000, &content).unwrap();
let outer = roster::seal_dissolved_edition(&Keys::generate(), &inner, &cid).unwrap();
let signer = roster::dissolved_tombstone_signer(&outer, &cid).expect("v0.4.0 probe must accept");
assert_eq!(signer, owner.public_key());
let folded = roster::fold_roster(&[inner.clone()], &cid, &std::collections::HashMap::new());
assert!(folded.dissolved_by.contains(&owner.public_key()));
let rec = folded.dissolved_editions.iter().find(|d| d.author == owner.public_key()).unwrap();
assert_eq!(rec.content, content);
let opened = roster::dissolved_tombstone_open(&outer, &cid).unwrap();
assert_eq!(opened.content, content);
assert_eq!(opened.author, owner.public_key());
}
#[test]
fn payloadless_never_shadows_the_pointer() {
let owner = Keys::generate();
let stranger = Keys::generate();
let cid = CommunityId([0x24u8; 32]);
let content = build_migration_content(&signpost(), Some("bTEyMw==".into())).unwrap();
let with_payload = roster::build_group_dissolved_edition_with_content(&owner, &cid, 100, &content).unwrap();
let plain_newer = roster::build_group_dissolved_edition(&owner, &cid, 200).unwrap();
let forged = roster::build_group_dissolved_edition_with_content(&stranger, &cid, 300, &content).unwrap();
let folded = roster::fold_roster(&[plain_newer, with_payload, forged], &cid, &std::collections::HashMap::new());
let (p, raw) = select_pointer(&folded.dissolved_editions, &owner.public_key().to_hex()).expect("payload survives");
assert_eq!(p.m.as_deref(), Some("bTEyMw=="));
assert_eq!(parse_migration_payload(&raw).unwrap(), p);
assert!(select_pointer(&folded.dissolved_editions, &Keys::generate().public_key().to_hex()).is_none());
}
#[test]
fn newest_payload_carrier_wins_with_id_tiebreak() {
let owner = Keys::generate();
let cid = CommunityId([0x33u8; 32]);
let mut sp_old = signpost();
sp_old.name = "old".into();
let mut sp_new = signpost();
sp_new.name = "new".into();
let a = roster::build_group_dissolved_edition_with_content(
&owner, &cid, 100, &build_migration_content(&sp_old, None).unwrap()).unwrap();
let b = roster::build_group_dissolved_edition_with_content(
&owner, &cid, 200, &build_migration_content(&sp_new, None).unwrap()).unwrap();
let folded = roster::fold_roster(&[a, b], &cid, &std::collections::HashMap::new());
let (p, _) = select_pointer(&folded.dissolved_editions, &owner.public_key().to_hex()).unwrap();
assert_eq!(p.signpost.name, "new");
}
}