feat(concord): recover a membership stranded by a Refounding

A rotation carries only (newRoot, newEpoch, rotator) — no recipient list
— so a member simply left out of the recipient set receives nothing and
is stranded on the dead epoch forever while everyone else moves on. It
applies to any member, the owner included, and cannot be prevented on the
receive side: there is nothing to check.

Armada does not prevent it either; it recovers, and this follows the same
approach. The invite link a membership was joined through is stored as
the anchor, and when that link later resolves to a HIGHER epoch, the
membership merges forward.

Uses Armada's exact wire names so the kind-13302 list stays compatible in
both directions: `invite_ref` (the link in bare `<naddr>#<fragment>`
form, host-stripped so a link minted by a different front end reduces to
the same anchor) and `excluded_at_epoch`, both at the entry level.

Details that decide whether this works at all:

- `merge()` lets a higher-epoch winner inherit the loser's `invite_ref`
  when it has none. Without it a two-device merge silently discards the
  only anchor recovery has, disarming it permanently.
- `adoptConcordRoot` carries `invite_ref`/`excluded_at_epoch` through a
  rotation; it rebuilt the entry field-by-field and would have dropped
  them at exactly the moment they matter.
- Merging forward preserves `heldRoots`, so prior-epoch history the
  member legitimately holds is not lost by recovering.
- Recovery requires a strictly higher epoch and a matching community id,
  so it is monotonic and cannot be steered by an unrelated bundle.

Hooked onto the existing Concord revision tick immediately after
`drainConcordRekeys`, because the two are halves of one problem: a
rotation you were included in arrives as a rekey to drain, one you were
excluded from produces no message at all and can only be found by polling
the link. Rate-limited to 15 minutes per community; an idle tick costs a
map lookup. Only a Live bundle recovers — an expired or revoked link is
not a missed rotation.

Verified by mutating the production code eight ways (dropping the anchor,
dropping heldRoots, dropping the epoch comparison, renaming the wire
keys, removing the merge inheritance, breaking bare-form parsing) and
confirming each produced exactly the expected failures.

Two things this surfaced, both left for their own change:

- Our parser does NOT round-trip unknown JSON keys — `ignoreUnknownKeys`
  plus closed DTOs — while Armada's format ends in `[k: string]:
  unknown`. So every Amethyst write of the community list silently strips
  fields Armada added that we do not model; it already discards the
  `refounder` field we parse but never re-emit. That is live interop data
  loss, caused by us, independent of this work.
- `mergeForward` keeps the entry's existing private-channel grants rather
  than adopting the bundle's, matching what `joinConcordViaInvite`
  already does. If a recovered member should pick up new-epoch grants,
  both paths need it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-19 20:36:52 -04:00
co-authored by Claude Opus 4.8
parent fac1bf5b5d
commit f8d8a2b135
6 changed files with 541 additions and 24 deletions
@@ -362,6 +362,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
@@ -371,6 +372,14 @@ private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not
/** Name of the default Concord community Admin role minted by "Make admin". */
private const val CONCORD_ADMIN_ROLE = "Admin"
/**
* How often a joined Concord community's stored invite link is re-resolved to check whether
* we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is
* rare and silent, so this trades detection latency for not turning the revision tick into a
* relay-fetch loop.
*/
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
@@ -2146,6 +2155,10 @@ class Account(
relays = bundle.relays,
name = bundle.name,
addedAt = TimeUtils.now() * 1000,
// Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a
// Refounding that leaves us out of the recipient set is recoverable later. See
// recoverStrandedConcordCommunities().
inviteRef = ConcordActions.bareInviteRef(url),
)
joinConcordCommunity(entry)
return ConcordInviteResult.Joined(bundle.communityId)
@@ -2603,6 +2616,10 @@ class Account(
relays = entry.relays,
name = entry.name,
addedAt = entry.addedAt,
// The invite_ref anchor must survive a rotation, or the *next* Refounding we're left
// out of would be unrecoverable.
inviteRef = entry.inviteRef,
excludedAtEpoch = entry.excludedAtEpoch,
)
sendMyPublicAndPrivateOutbox(concordChannelList.follow(next))
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
@@ -2623,12 +2640,11 @@ class Account(
* which forks a community across clients. Self-escalation to BAN is prevented
* upstream by the role rank gate in AuthorityResolver.
*
* KNOWN LIMIT — a rotation carries only (newRoot, newEpoch, rotator); there is no
* recipient list, so a receiver cannot tell who was left out, and a BAN-holder can
* evict anyone (the owner included) by omission. Armada does not prevent this
* either; it *recovers* from it, re-resolving the invite link the member joined
* through and merging forward to the higher epoch ("stranded recovery"). Amethyst
* has no equivalent yet, so a stranded member stays stranded. Tracked for CORD-06.
* A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list,
* so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the
* owner included) by omission — nothing on this receive path can prevent it. The
* cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves
* the invite link the membership was joined through and merges forward.
*/
private suspend fun drainConcordRekeys() {
if (!isWriteable()) return
@@ -2655,6 +2671,67 @@ class Account(
}
}
// Last time we re-resolved each community's invite_ref, so the recovery sweep rides the
// Concord revision tick (which fires on every structural change) without turning it into a
// relay-fetch loop.
private val lastConcordRecoveryCheck = ConcurrentHashMap<String, Long>()
/**
* Stranded recovery (CORD-05/06 receive path). A Refounding carries only
* `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left
* out of the rekey recipient set receives nothing and sits on the dead epoch
* forever while everyone else moves on. This happens to any member, the owner
* included, and [drainConcordRekeys] cannot prevent it: there is no message to
* miss detecting.
*
* The way back is the invite link the membership was joined through
* ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and
* carried through every rotation by [adoptConcordRoot]). The community keeps
* re-minting its bundle at that same addressable coordinate, so a bundle there at
* a **strictly higher** epoch than ours proves we were left behind — and carries
* the new root. Same or lower epoch is a no-op. Memberships with no link (direct
* invites, legacy entries) are inert here; that is expected, not an error.
*
* The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps
* both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the
* entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays
* derivable). We then re-announce the Guestbook at the new epoch, exactly as an
* ordinary rotation does, so the recovered member is visible to whoever refounds
* next instead of being silently dropped again.
*
* Called on the Concord revision tick, but rate-limited per community
* ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup.
*/
private suspend fun recoverStrandedConcordCommunities() {
if (!isWriteable()) return
val now = TimeUtils.nowMillis()
for (entry in concordChannelList.liveCommunities.value) {
val inviteRef = entry.inviteRef ?: continue
val last = lastConcordRecoveryCheck[entry.id]
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
lastConcordRecoveryCheck[entry.id] = now
val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
val relays =
(
parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } +
entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
).toSet()
if (relays.isEmpty()) continue
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = client.fetchAll(filters = filters)
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged))
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
}
}
/**
* Replace the community metadata (name / icon / description / relays) with a new
* Control-Plane edition. Honored on fold only when this account holds
@@ -5410,6 +5487,9 @@ class Account(
refreshConcordChannelIndex()
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
// found by re-resolving the invite link we joined through. Rate-limited internally.
runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.actions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.Guestbook
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction
@@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordStrandedRecovery
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink
import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink
@@ -351,6 +353,23 @@ object ConcordActions {
/** Parses a shareable invite URL into its pointer + private fragment. */
fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url)
/**
* Reduces an invite URL to the domain-agnostic bare `<naddr>#<fragment>` form
* stored as an entry's `invite_ref` (the stranded-recovery anchor). Null if the
* link is unparseable.
*/
fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url)
/**
* Merges a stranded membership forward onto a higher-epoch [bundle] resolved at
* its own stored invite link, or null when there is nothing to recover. See
* [ConcordStrandedRecovery].
*/
fun recoverStranded(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle)
/** Decrypts + validates a fetched bundle event with the link token; null if invalid. */
fun openBundle(
bundleEvent: Event,
@@ -49,6 +49,15 @@ class PrivateChannelKey(
* [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached
* display [name]. [addedAt] is the wire join timestamp (ms) that tiebreaks
* liveness against tombstones.
*
* [inviteRef] is the invite link this membership was joined through, kept in the
* domain-agnostic bare `<naddr>#<fragment>` form (Armada's `invite_ref`, CORD-05
* §2/§3). It is the anchor for stranded recovery: a Refounding carries no
* recipient list, so a member left out of the rekey set never hears about the new
* epoch — re-resolving this link is the only way back. Entries joined without a
* link (direct invites, legacy entries) simply have none and are inert for
* recovery. [excludedAtEpoch] records the epoch at which we observed ourselves
* excluded, if ever.
*/
@Serializable
class ConcordCommunityListEntry(
@@ -62,6 +71,8 @@ class ConcordCommunityListEntry(
val relays: List<String> = emptyList(),
val name: String = "",
val addedAt: Long = 0,
val inviteRef: String? = null,
val excludedAtEpoch: Long? = null,
)
/**
@@ -118,6 +129,8 @@ object ConcordCommunityList {
val seed: JoinMaterialWire? = null,
val current: JoinMaterialWire? = null,
@SerialName("added_at") val addedAt: Long = 0,
@SerialName("invite_ref") val inviteRef: String? = null,
@SerialName("excluded_at_epoch") val excludedAtEpoch: Long? = null,
)
@Serializable
@@ -145,19 +158,24 @@ object ConcordCommunityList {
heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) },
)
private fun JoinMaterialWire.toEntry(addedAt: Long) =
ConcordCommunityListEntry(
id = communityId,
owner = owner,
ownerSalt = ownerSalt,
root = communityRoot,
rootEpoch = rootEpoch,
heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) },
privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) },
relays = relays,
name = name,
addedAt = addedAt,
)
private fun JoinMaterialWire.toEntry(
addedAt: Long,
inviteRef: String? = null,
excludedAtEpoch: Long? = null,
) = ConcordCommunityListEntry(
id = communityId,
owner = owner,
ownerSalt = ownerSalt,
root = communityRoot,
rootEpoch = rootEpoch,
heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) },
privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) },
relays = relays,
name = name,
addedAt = addedAt,
inviteRef = inviteRef,
excludedAtEpoch = excludedAtEpoch,
)
// ---- build / codec --------------------------------------------------------
@@ -183,6 +201,8 @@ object ConcordCommunityList {
seed = jm,
current = jm,
addedAt = e.addedAt,
inviteRef = e.inviteRef,
excludedAtEpoch = e.excludedAtEpoch,
)
},
tombstones = emptyList(),
@@ -206,7 +226,7 @@ object ConcordCommunityList {
doc.entries.mapNotNull { e ->
val removedAt = latestRemoval[e.communityId]
if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null
(e.current ?: e.seed)?.toEntry(e.addedAt)
(e.current ?: e.seed)?.toEntry(e.addedAt, e.inviteRef, e.excludedAtEpoch)
}
} catch (_: Exception) {
emptyList()
@@ -238,8 +258,33 @@ object ConcordCommunityList {
val byId = LinkedHashMap<String, ConcordCommunityListEntry>()
for (e in a + b) {
val existing = byId[e.id]
if (existing == null || e.rootEpoch > existing.rootEpoch) byId[e.id] = e
if (existing == null) {
byId[e.id] = e
} else if (e.rootEpoch > existing.rootEpoch) {
// A winner without an invite_ref inherits the loser's: that link is the only anchor
// stranded recovery has, and dropping it on a merge would disarm recovery forever.
byId[e.id] = if (e.inviteRef == null) e.withInviteRef(existing.inviteRef) else e
} else if (existing.inviteRef == null && e.inviteRef != null) {
byId[e.id] = existing.withInviteRef(e.inviteRef)
}
}
return byId.values.toList()
}
/** Copy of this entry carrying [inviteRef]; every other field untouched. */
fun ConcordCommunityListEntry.withInviteRef(inviteRef: String?) =
ConcordCommunityListEntry(
id = id,
owner = owner,
ownerSalt = ownerSalt,
root = root,
rootEpoch = rootEpoch,
heldRoots = heldRoots,
privateChannels = privateChannels,
relays = relays,
name = name,
addedAt = addedAt,
inviteRef = inviteRef,
excludedAtEpoch = excludedAtEpoch,
)
}
@@ -169,7 +169,12 @@ object ConcordInviteLink {
return "$trimmed/invite/$naddr#${encodeFragment(token, relays)}"
}
/** Parses a full invite URL back into its pointer + fragment, or null if malformed. */
/**
* Parses an invite link back into its pointer + fragment, or null if malformed.
* Accepts both the full `{base}/invite/{naddr}#{fragment}` URL and the
* domain-agnostic bare `{naddr}#{fragment}` form produced by [bareForm], so a
* link stored by one front end still resolves when shared through another.
*/
fun parseUrl(url: String): ParsedInviteLink? {
val hash = url.indexOf('#')
if (hash < 0) return null
@@ -180,10 +185,26 @@ object ConcordInviteLink {
return null
}
val marker = url.indexOf("/invite/")
if (marker < 0) return null
val naddr = url.substring(marker + "/invite/".length, hash)
val naddr =
if (marker >= 0) {
url.substring(marker + "/invite/".length, hash)
} else {
// Bare `<naddr>#<fragment>` — no host, no path.
url.substring(0, hash)
}
val parsed = NAddress.parse(naddr) ?: return null
if (parsed.kind != ConcordInviteBundleEvent.KIND) return null
return ParsedInviteLink(naddr, parsed.author, parsed.kind, fragment)
}
/**
* Reduces any invite link to the domain-agnostic bare `{naddr}#{fragment}` form
* (CORD-05 §2/§3 `invite_ref`), dropping any `https://host/invite/` prefix so a
* membership anchored through one front end still matches a link shared via
* another. Returns null if [url] is not a parseable invite link.
*/
fun bareForm(url: String): String? {
val parsed = parseUrl(url) ?: return null
return parsed.naddr + "#" + url.substring(url.indexOf('#') + 1)
}
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
/**
* Stranded recovery (CORD-05/06).
*
* A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no
* recipient list** — so a member who is simply left out of the rekey recipient
* set receives nothing and is silently stranded on the dead epoch forever, while
* everyone else moves on. This is true of any member, the owner included, and
* cannot be prevented on the receive side.
*
* The way out is the invite link the membership was joined through
* ([ConcordCommunityListEntry.inviteRef]): the community keeps publishing its
* bundle at that same addressable coordinate, re-minted at the current epoch. So
* a member who re-resolves their own join link and finds a **higher** epoch than
* the one they hold knows they were left behind, and can merge forward.
*
* This object holds only the pure decision + merge; fetching and unlocking the
* bundle at the link is the caller's job.
*/
object ConcordStrandedRecovery {
/**
* True when [bundle], resolved at [entry]'s stored invite link, proves we were
* left behind: it must describe the same community and sit at a strictly higher
* epoch. Same or lower is a no-op (we are current, or the bundle is stale).
*/
fun isStranded(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
): Boolean =
entry.inviteRef != null &&
bundle.communityId.equals(entry.id, ignoreCase = true) &&
bundle.rootEpoch > entry.rootEpoch
/**
* Merges [entry] forward onto the higher-epoch [bundle], or returns null when
* there is nothing to do ([isStranded] is false) — so the caller can treat null
* as "stay put" without a second check.
*
* The merge is epoch-monotonic (it never moves backwards, by construction of
* [isStranded]) and preserves two things the naive "adopt the bundle" would
* destroy:
*
* - the [ConcordCommunityListEntry.inviteRef] anchor, so the next Refounding we
* are left out of is recoverable too; and
* - the existing [ConcordCommunityListEntry.heldRoots], plus the root we are
* leaving, so prior-epoch history the member legitimately holds stays
* derivable instead of going dark on catch-up.
*/
fun mergeForward(
entry: ConcordCommunityListEntry,
bundle: CommunityInvite,
): ConcordCommunityListEntry? {
if (!isStranded(entry, bundle)) return null
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch }
return ConcordCommunityListEntry(
id = entry.id,
owner = entry.owner,
ownerSalt = entry.ownerSalt,
root = bundle.communityRoot,
rootEpoch = bundle.rootEpoch,
heldRoots = held,
privateChannels = entry.privateChannels,
relays = if (bundle.relays.isNotEmpty()) bundle.relays else entry.relays,
name = entry.name.ifEmpty { bundle.name },
addedAt = entry.addedAt,
inviteRef = entry.inviteRef,
// We were excluded from the epoch we were sitting on when we found the gap.
excludedAtEpoch = entry.rootEpoch,
)
}
}
@@ -0,0 +1,255 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.concord.cord05Invites
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Stranded recovery: a member left out of a Refounding's recipient set gets no rekey at
* all, so the only way they learn about the new epoch is by re-resolving the invite link
* they joined through and finding a higher-epoch bundle there.
*/
class ConcordStrandedRecoveryTest {
private val communityId = "11".repeat(32)
private val token = ByteArray(16) { it.toByte() }
private val linkSigner = KeyPair().pubKey.toHexKey()
private val inviteRef = ConcordInviteLink.buildUrl("https://amethyst.social", linkSigner, token).substringAfter("/invite/")
private fun entry(
epoch: Long,
ref: String? = inviteRef,
heldRoots: List<HeldRoot> = emptyList(),
) = ConcordCommunityListEntry(
id = communityId,
owner = "0f".repeat(32),
ownerSalt = "aa".repeat(32),
root = "bb".repeat(32),
rootEpoch = epoch,
heldRoots = heldRoots,
relays = listOf("wss://relay.example"),
name = "Gamers",
addedAt = 1_700_000_000_000L,
inviteRef = ref,
)
private fun bundle(
epoch: Long,
root: String = "cc".repeat(32),
id: String = communityId,
) = CommunityInvite(
communityId = id,
owner = "0f".repeat(32),
ownerSalt = "aa".repeat(32),
communityRoot = root,
rootEpoch = epoch,
relays = listOf("wss://relay.example"),
name = "Gamers",
)
// ---- merge forward --------------------------------------------------------
@Test
fun higherEpochBundleMergesForwardKeepingAnchorAndHistory() {
val prior = HeldRoot(0L, "aa".repeat(32))
val stranded = entry(epoch = 1, heldRoots = listOf(prior))
val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5))
assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind")
// adopted the new epoch's access root
assertEquals(5L, merged.rootEpoch)
assertEquals("cc".repeat(32), merged.root)
// the anchor survives, or the *next* exclusion would be unrecoverable
assertEquals(inviteRef, merged.inviteRef)
// prior-epoch history we legitimately hold is not lost, and the root we just left
// is added so epoch-1 channels stay derivable
assertEquals(setOf(0L, 1L), merged.heldRoots.map { it.epoch }.toSet())
assertTrue(merged.heldRoots.any { it.epoch == 0L && it.key == prior.key })
assertTrue(merged.heldRoots.any { it.epoch == 1L && it.key == "bb".repeat(32) })
// identity is untouched and we record where we were dropped
assertEquals(communityId, merged.id)
assertEquals(stranded.addedAt, merged.addedAt)
assertEquals(1L, merged.excludedAtEpoch)
}
@Test
fun sameEpochBundleIsANoOp() {
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5)))
assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5)))
}
@Test
fun lowerEpochBundleIsANoOp() {
// Epoch-monotonic: a stale bundle must never walk the membership backwards.
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3)))
}
@Test
fun entryWithoutInviteRefIsInert() {
// Direct invites and legacy entries have no anchor — expected, not an error.
val noAnchor = entry(epoch = 1, ref = null)
assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9)))
assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9)))
}
@Test
fun bundleForAnotherCommunityIsIgnored() {
assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32))))
}
// ---- the bare `<naddr>#<fragment>` anchor form ----------------------------
@Test
fun bareFormStripsTheHostSoAnyFrontEndsLinkMatches() {
val amethyst = ConcordInviteLink.buildUrl("https://amethyst.social", linkSigner, token)
val armada = ConcordInviteLink.buildUrl("https://someother.example", linkSigner, token)
val bare = ConcordInviteLink.bareForm(amethyst)
assertNotNull(bare)
assertFalse(bare.contains("amethyst.social"))
assertFalse(bare.contains("/invite/"))
assertTrue(bare.startsWith("naddr"))
// domain-agnostic: the same invite shared through a different front end reduces
// to the identical anchor, which is the whole point of storing the bare form
assertEquals(bare, ConcordInviteLink.bareForm(armada))
}
@Test
fun bareFormReParsesBackToTheSamePointerAndToken() {
val bare = ConcordInviteLink.bareForm(ConcordInviteLink.buildUrl("https://amethyst.social", linkSigner, token))!!
val parsed = ConcordInviteLink.parseUrl(bare)
assertNotNull(parsed, "the stored bare anchor must be re-resolvable, or recovery can never fire")
assertEquals(linkSigner, parsed.linkSignerPubKey)
assertEquals(token.toList(), parsed.fragment.token.toList())
}
@Test
fun bareFormOfGarbageIsNull() {
assertNull(ConcordInviteLink.bareForm("https://amethyst.social/invite/nope"))
assertNull(ConcordInviteLink.bareForm("not a link"))
}
// ---- wire round-trip (Armada interop) ------------------------------------
@Test
fun inviteRefAndExcludedAtEpochRoundTripOnTheWire() {
val original =
ConcordCommunityListEntry(
id = communityId,
owner = "0f".repeat(32),
ownerSalt = "aa".repeat(32),
root = "cc".repeat(32),
rootEpoch = 5,
heldRoots = listOf(HeldRoot(0L, "aa".repeat(32)), HeldRoot(1L, "bb".repeat(32))),
relays = listOf("wss://relay.example"),
name = "Gamers",
addedAt = 1_700_000_000_000L,
inviteRef = inviteRef,
excludedAtEpoch = 1L,
)
val json = ConcordCommunityList.encode(listOf(original))
// Armada's field names, verbatim — a mismatch silently breaks interop both ways.
assertTrue(json.contains("\"invite_ref\""), "must serialize as invite_ref: $json")
assertTrue(json.contains("\"excluded_at_epoch\""), "must serialize as excluded_at_epoch: $json")
val back = ConcordCommunityList.decode(json).single()
assertEquals(inviteRef, back.inviteRef)
assertEquals(1L, back.excludedAtEpoch)
assertEquals(5L, back.rootEpoch)
assertEquals("cc".repeat(32), back.root)
assertEquals(listOf(0L, 1L), back.heldRoots.map { it.epoch })
}
@Test
fun decodesArmadaEntryCarryingInviteRef() {
// Shape as Armada's communityList.ts writes it, with invite_ref / excluded_at_epoch
// at the ENTRY level (not inside the JoinMaterial).
val json =
"""
{
"entries": [
{
"community_id": "$communityId",
"current": {
"community_id": "$communityId",
"owner": "${"0f".repeat(32)}",
"owner_salt": "${"aa".repeat(32)}",
"community_root": "${"cc".repeat(32)}",
"root_epoch": 4
},
"added_at": 1700000000000,
"invite_ref": "$inviteRef",
"excluded_at_epoch": 2
}
],
"tombstones": []
}
""".trimIndent()
val entry = ConcordCommunityList.decode(json).single()
assertEquals(inviteRef, entry.inviteRef)
assertEquals(2L, entry.excludedAtEpoch)
assertEquals(4L, entry.rootEpoch)
}
@Test
fun anEntryWithoutInviteRefStillDecodes() {
// Legacy lists (and Armada entries joined by direct invite) carry no invite_ref.
val json = ConcordCommunityList.encode(listOf(entry(epoch = 1, ref = null)))
val back = ConcordCommunityList.decode(json).single()
assertNull(back.inviteRef)
assertNull(back.excludedAtEpoch)
}
@Test
fun mergeKeepsTheAnchorWhenTheHigherEpochCopyLacksIt() {
// Two devices: one holds the anchor at the old epoch, the other rotated forward
// without it. Dropping the anchor here would disarm recovery permanently.
val anchored = entry(epoch = 1, ref = inviteRef)
val rotatedNoAnchor = entry(epoch = 4, ref = null)
val merged = ConcordCommunityList.merge(listOf(anchored), listOf(rotatedNoAnchor)).single()
assertEquals(4L, merged.rootEpoch)
assertEquals(inviteRef, merged.inviteRef)
// and the same regardless of argument order
val other = ConcordCommunityList.merge(listOf(rotatedNoAnchor), listOf(anchored)).single()
assertEquals(4L, other.rootEpoch)
assertEquals(inviteRef, other.inviteRef)
}
}