mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
feat(concord): tell a stranded member they were left behind
A CORD-06 Refounding carries no recipient list, so a member left out of the rekey set sits on a dead root while everyone else moves on. Every plane address they still derive is dead, and a dead address returns nothing rather than erroring — so the community rendered as an ordinary quiet one, with no affordance anywhere that could hint otherwise. Recovery ran on a 15-minute timer and logged. Two things were missing, not one. Detection. The rekey drain treated "no blob for me" as nothing to do. But the rotation's wraps sit at an address every current member derives, so an excluded member can watch the rotation happen and find no blob addressed to them — that is a direct signal, not an inference from silence. It is now recorded, gated on ConcordRefounding.isCompleteRotation: the recipient blobs are chunked, so on a partial fetch the absence of ours proves nothing, and exclusion may only be concluded once chunk indices 0..total-1 are all in hand. Reporting. ConcordCommunityHealth per community, rendered by ConcordCommunityBanner above the feed on both the channel list and an open channel — because these states explain what you are seeing, and the composer slot at the bottom is the wrong place to explain an empty feed. Dissolution moves here too; the composer notice could only ever show it to someone already inside a channel. The health value is a single value rather than a set of flags, so two banners cannot stack — that is the difference between a status surface and chrome. The recovery sweep's collapsed `continue`s now distinguish revoked / expired / unreadable / no-anchor, so the copy can ask for a new invite when that is genuinely the fix. A dead link is only reported once it is actually what stands between us and getting back in; on a community we are current with it costs nothing and would be alarming noise. While a way back exists the banner shows progress, since recovery is automatic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q3KXHjmCpUDzrSRyxatbyB
This commit is contained in:
@@ -42,6 +42,8 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
|||||||
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
|
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
|
||||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
|
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunityHealth
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunityHealthState
|
||||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
|
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
|
||||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
|
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
|
||||||
@@ -659,6 +661,11 @@ class Account(
|
|||||||
// Every folded channel, openable or not: a channel we hold no key for is hidden from the
|
// Every folded channel, openable or not: a channel we hold no key for is hidden from the
|
||||||
// display surfaces, but its object still learns the truth so arriving at one anyway (a
|
// display surfaces, but its object still learns the truth so arriving at one anyway (a
|
||||||
// stale route) explains itself instead of offering a composer that can't publish.
|
// stale route) explains itself instead of offering a composer that can't publish.
|
||||||
|
// A dissolved community is community-wide and terminal, so the banner is the right place
|
||||||
|
// for it: the composer notice inside a channel only reaches someone already in one.
|
||||||
|
// Never cleared here — nothing un-dissolves, and a tick must not wipe a Stranded verdict.
|
||||||
|
if (state.dissolved) concordHealth.set(communityId, ConcordCommunityHealth.Dissolved)
|
||||||
|
|
||||||
val openable = session.openableChannelIds().toSet()
|
val openable = session.openableChannelIds().toSet()
|
||||||
for (channelIdHex in state.channels.keys) {
|
for (channelIdHex in state.channels.keys) {
|
||||||
val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
|
val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex))
|
||||||
@@ -810,6 +817,13 @@ class Account(
|
|||||||
// own chat bubbles.
|
// own chat bubbles.
|
||||||
val chatDeliveryTracker = ChatDeliveryTracker(client)
|
val chatDeliveryTracker = ChatDeliveryTracker(client)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-community Concord health (stranded / catching up / recovery failed / dissolved), written by
|
||||||
|
* [drainConcordRekeys] and [recoverStrandedConcordCommunities] and read by the community banner.
|
||||||
|
* In-memory: a restart re-derives it from the next rekey drain or recovery sweep.
|
||||||
|
*/
|
||||||
|
val concordHealth = ConcordCommunityHealthState()
|
||||||
|
|
||||||
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
|
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
|
||||||
|
|
||||||
val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) }
|
val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) }
|
||||||
@@ -2976,14 +2990,37 @@ class Account(
|
|||||||
val wraps = session.pendingBaseRekeyWraps()
|
val wraps = session.pendingBaseRekeyWraps()
|
||||||
if (wraps.isEmpty()) continue
|
if (wraps.isEmpty()) continue
|
||||||
val entry = session.entry
|
val entry = session.entry
|
||||||
|
val baseRekey = session.nextBaseRekeyKey()
|
||||||
|
val priorRoot = entry.root.hexToByteArray()
|
||||||
val received =
|
val received =
|
||||||
ConcordActions.openBaseRekey(
|
ConcordActions.openBaseRekey(
|
||||||
wraps = wraps,
|
wraps = wraps,
|
||||||
baseRekey = session.nextBaseRekeyKey(),
|
baseRekey = baseRekey,
|
||||||
recipientSigner = signer,
|
recipientSigner = signer,
|
||||||
priorRoot = entry.root.hexToByteArray(),
|
priorRoot = priorRoot,
|
||||||
rootEpoch = entry.rootEpoch,
|
rootEpoch = entry.rootEpoch,
|
||||||
) ?: continue
|
)
|
||||||
|
if (received == null) {
|
||||||
|
// A rotation we can see but were not given: every chunk is present and none carries a
|
||||||
|
// blob for us. That is the stranding signal, and it used to be dropped here — leaving
|
||||||
|
// the community looking merely quiet, because each plane address we still derive is
|
||||||
|
// dead and a dead address returns nothing rather than failing. The completeness guard
|
||||||
|
// matters: with a partial fetch, our blob's absence proves nothing.
|
||||||
|
if (ConcordActions.isCompleteBaseRotation(wraps, baseRekey, priorRoot, entry.rootEpoch)) {
|
||||||
|
Log.w("Concord", "Stranded: ${entry.id} excluded from the rotation to epoch ${entry.rootEpoch + 1}")
|
||||||
|
concordHealth.set(
|
||||||
|
entry.id,
|
||||||
|
ConcordCommunityHealth.Stranded(
|
||||||
|
strandedAtEpoch = entry.rootEpoch,
|
||||||
|
newEpoch = entry.rootEpoch + 1,
|
||||||
|
// The stored invite link is the only anchor that survives a rotation we
|
||||||
|
// weren't party to; without one, no automatic way back exists.
|
||||||
|
recoverable = entry.inviteRef != null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (received.newEpoch <= entry.rootEpoch) continue
|
if (received.newEpoch <= entry.rootEpoch) continue
|
||||||
val authority = session.state.value?.authority ?: continue
|
val authority = session.state.value?.authority ?: continue
|
||||||
|
|
||||||
@@ -2992,6 +3029,25 @@ class Account(
|
|||||||
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
|
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
|
||||||
if (!authorized) continue
|
if (!authorized) continue
|
||||||
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
|
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
|
||||||
|
// We were included after all (or a later rotation reached us): retract any banner.
|
||||||
|
concordHealth.clearIf(entry.id) { it !is ConcordCommunityHealth.Dissolved }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalates a stranded community to [ConcordCommunityHealth.RecoveryFailed].
|
||||||
|
*
|
||||||
|
* Gated on already knowing we are stranded: a dead invite link on a community we are current with
|
||||||
|
* costs us nothing today, and reporting it would be alarming and useless. It only matters once it
|
||||||
|
* is the thing standing between us and getting back in.
|
||||||
|
*/
|
||||||
|
private fun reportConcordRecoveryFailure(
|
||||||
|
communityId: String,
|
||||||
|
reason: ConcordCommunityHealth.RecoveryFailed.Reason,
|
||||||
|
) {
|
||||||
|
val current = concordHealth.currentFor(communityId)
|
||||||
|
if (current is ConcordCommunityHealth.Stranded || current is ConcordCommunityHealth.CatchingUp) {
|
||||||
|
concordHealth.set(communityId, ConcordCommunityHealth.RecoveryFailed(reason))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3030,12 +3086,22 @@ class Account(
|
|||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
val now = TimeUtils.nowMillis()
|
val now = TimeUtils.nowMillis()
|
||||||
for (entry in concordChannelList.liveCommunities.value) {
|
for (entry in concordChannelList.liveCommunities.value) {
|
||||||
val inviteRef = entry.inviteRef ?: continue
|
val inviteRef = entry.inviteRef
|
||||||
|
if (inviteRef == null) {
|
||||||
|
// No anchor to re-resolve. Only worth saying when we already know we were left out —
|
||||||
|
// otherwise it is a latent risk, not a current problem, and belongs in no banner.
|
||||||
|
reportConcordRecoveryFailure(entry.id, ConcordCommunityHealth.RecoveryFailed.Reason.NO_ANCHOR)
|
||||||
|
continue
|
||||||
|
}
|
||||||
val last = lastConcordRecoveryCheck[entry.id]
|
val last = lastConcordRecoveryCheck[entry.id]
|
||||||
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
|
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
|
||||||
lastConcordRecoveryCheck[entry.id] = now
|
lastConcordRecoveryCheck[entry.id] = now
|
||||||
|
|
||||||
val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
|
val parsed = ConcordActions.parseInviteLink(inviteRef)
|
||||||
|
if (parsed == null) {
|
||||||
|
reportConcordRecoveryFailure(entry.id, ConcordCommunityHealth.RecoveryFailed.Reason.LINK_UNREADABLE)
|
||||||
|
continue
|
||||||
|
}
|
||||||
val relays =
|
val relays =
|
||||||
(
|
(
|
||||||
parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } +
|
parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } +
|
||||||
@@ -3043,16 +3109,49 @@ class Account(
|
|||||||
).toSet()
|
).toSet()
|
||||||
if (relays.isEmpty()) continue
|
if (relays.isEmpty()) continue
|
||||||
|
|
||||||
|
// Only show progress once we know there is something to fix: re-resolving the link is a
|
||||||
|
// routine 15-minute check, and a banner on every tick would be noise.
|
||||||
|
val wasStranded = concordHealth.currentFor(entry.id) is ConcordCommunityHealth.Stranded
|
||||||
|
if (wasStranded) concordHealth.set(entry.id, ConcordCommunityHealth.CatchingUp(entry.rootEpoch))
|
||||||
|
|
||||||
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
|
||||||
val wraps = client.fetchAll(filters = filters)
|
val wraps = client.fetchAll(filters = filters)
|
||||||
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
|
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed. Which
|
||||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
// kind of dead it is decides whether the user can do anything about it, so report it
|
||||||
|
// rather than collapsing every case into one silent `continue`.
|
||||||
|
val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)
|
||||||
|
val bundle = (status as? InviteBundleStatus.Live)?.invite
|
||||||
|
if (bundle == null) {
|
||||||
|
val reason =
|
||||||
|
when (status) {
|
||||||
|
is InviteBundleStatus.Revoked -> ConcordCommunityHealth.RecoveryFailed.Reason.LINK_REVOKED
|
||||||
|
is InviteBundleStatus.Expired -> ConcordCommunityHealth.RecoveryFailed.Reason.LINK_EXPIRED
|
||||||
|
is InviteBundleStatus.Unreadable -> ConcordCommunityHealth.RecoveryFailed.Reason.LINK_UNREADABLE
|
||||||
|
// Absent: nothing found on any relay, which is usually transient — leave the
|
||||||
|
// Stranded banner (or nothing) in place rather than claiming a dead link.
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (reason != null) {
|
||||||
|
reportConcordRecoveryFailure(entry.id, reason)
|
||||||
|
} else if (wasStranded) {
|
||||||
|
// Put the stranded verdict back; catching-up implied progress we no longer have.
|
||||||
|
concordHealth.set(entry.id, ConcordCommunityHealth.Stranded(entry.rootEpoch, entry.rootEpoch + 1, recoverable = true))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
|
val merged = ConcordActions.recoverStranded(entry, bundle)
|
||||||
|
if (merged == null) {
|
||||||
|
// The link resolves and we are already at its epoch: nothing was missed after all.
|
||||||
|
if (wasStranded) concordHealth.clearIf(entry.id) { it is ConcordCommunityHealth.CatchingUp }
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
||||||
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
||||||
sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged))
|
sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged))
|
||||||
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
|
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
|
||||||
|
// Caught up: the banner has nothing left to report.
|
||||||
|
concordHealth.clearIf(entry.id) { it !is ConcordCommunityHealth.Dissolved }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-55
@@ -307,62 +307,68 @@ fun ConcordChannelListScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
// Only channels this account can open. A private channel whose per-member key was never
|
// Community-wide status first: stranding and dissolution explain why the list below may be
|
||||||
// delivered to us has no derivable plane (CORD-03 §1), so its row would open a room that can
|
// empty or frozen, so they belong above it rather than inside a channel.
|
||||||
// never load — the reference client omits it for the same reason.
|
Column(Modifier.padding(padding)) {
|
||||||
val channels =
|
ConcordCommunityBanner(communityId, accountViewModel)
|
||||||
remember(state, session, revision) {
|
|
||||||
val folded = state?.channels.orEmpty()
|
// Only channels this account can open. A private channel whose per-member key was never
|
||||||
session?.openableChannelIds().orEmpty().mapNotNull { id -> folded[id]?.let { id to it } }
|
// delivered to us has no derivable plane (CORD-03 §1), so its row would open a room that can
|
||||||
}
|
// never load — the reference client omits it for the same reason.
|
||||||
if (channels.isEmpty()) {
|
val channels =
|
||||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
remember(state, session, revision) {
|
||||||
Text(
|
val folded = state?.channels.orEmpty()
|
||||||
stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty),
|
session?.openableChannelIds().orEmpty().mapNotNull { id -> folded[id]?.let { id to it } }
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
}
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
if (channels.isEmpty()) {
|
||||||
)
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
}
|
Text(
|
||||||
} else {
|
stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty),
|
||||||
// Bottom room for the FAB, which the Scaffold's `padding` deliberately doesn't account for
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
// (a FAB overlays content) — without it the last row's manager overflow menu sits under it
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
// and can't be tapped. As contentPadding so rows scroll *through* the strip rather than the
|
|
||||||
// viewport shrinking. Gated on [canManageChannels] because that is what renders the FAB:
|
|
||||||
// a plain member has nothing to clear, and no reason to lose the space.
|
|
||||||
LazyColumn(
|
|
||||||
Modifier.fillMaxSize().padding(padding),
|
|
||||||
contentPadding = PaddingValues(bottom = if (canManageChannels) FAB_CLEARANCE else 0.dp),
|
|
||||||
) {
|
|
||||||
items(channels, key = { it.first }) { entry ->
|
|
||||||
val def = entry.second.definition
|
|
||||||
val name = def.name.ifBlank { entry.first }
|
|
||||||
val icon =
|
|
||||||
when {
|
|
||||||
def.voice == true -> MaterialSymbols.Mic
|
|
||||||
def.private == true -> MaterialSymbols.Lock
|
|
||||||
else -> MaterialSymbols.Tag
|
|
||||||
}
|
|
||||||
val typingAuthors =
|
|
||||||
remember(typingMap, typingNow, entry.first) {
|
|
||||||
(typingMap[entry.first] ?: emptyMap())
|
|
||||||
.filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS }
|
|
||||||
.keys
|
|
||||||
.sorted()
|
|
||||||
}
|
|
||||||
ConcordChannelListRow(
|
|
||||||
communityId = communityId,
|
|
||||||
channelKey = entry.first,
|
|
||||||
channelName = name,
|
|
||||||
icon = icon,
|
|
||||||
isVoice = def.voice == true,
|
|
||||||
typingAuthors = typingAuthors,
|
|
||||||
canManageChannels = canManageChannels,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
onClick = { nav.nav(Route.Concord(communityId, entry.first)) },
|
|
||||||
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) },
|
|
||||||
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) },
|
|
||||||
)
|
)
|
||||||
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
}
|
||||||
|
} else {
|
||||||
|
// Bottom room for the FAB, which the Scaffold's `padding` deliberately doesn't account for
|
||||||
|
// (a FAB overlays content) — without it the last row's manager overflow menu sits under it
|
||||||
|
// and can't be tapped. As contentPadding so rows scroll *through* the strip rather than the
|
||||||
|
// viewport shrinking. Gated on [canManageChannels] because that is what renders the FAB:
|
||||||
|
// a plain member has nothing to clear, and no reason to lose the space.
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = if (canManageChannels) FAB_CLEARANCE else 0.dp),
|
||||||
|
) {
|
||||||
|
items(channels, key = { it.first }) { entry ->
|
||||||
|
val def = entry.second.definition
|
||||||
|
val name = def.name.ifBlank { entry.first }
|
||||||
|
val icon =
|
||||||
|
when {
|
||||||
|
def.voice == true -> MaterialSymbols.Mic
|
||||||
|
def.private == true -> MaterialSymbols.Lock
|
||||||
|
else -> MaterialSymbols.Tag
|
||||||
|
}
|
||||||
|
val typingAuthors =
|
||||||
|
remember(typingMap, typingNow, entry.first) {
|
||||||
|
(typingMap[entry.first] ?: emptyMap())
|
||||||
|
.filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS }
|
||||||
|
.keys
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
ConcordChannelListRow(
|
||||||
|
communityId = communityId,
|
||||||
|
channelKey = entry.first,
|
||||||
|
channelName = name,
|
||||||
|
icon = icon,
|
||||||
|
isVoice = def.voice == true,
|
||||||
|
typingAuthors = typingAuthors,
|
||||||
|
canManageChannels = canManageChannels,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onClick = { nav.nav(Route.Concord(communityId, entry.first)) },
|
||||||
|
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) },
|
||||||
|
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) },
|
||||||
|
)
|
||||||
|
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -211,6 +211,10 @@ fun ConcordChannelScreen(
|
|||||||
.consumeWindowInsets(padding)
|
.consumeWindowInsets(padding)
|
||||||
.imePadding(),
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
|
// Above the feed, because what it reports explains the feed. A stranded community's
|
||||||
|
// channel is not empty-because-quiet, it is empty because every address we derive is dead.
|
||||||
|
ConcordCommunityBanner(communityId, accountViewModel)
|
||||||
|
|
||||||
Column(Modifier.fillMaxHeight().weight(1f, true)) {
|
Column(Modifier.fillMaxHeight().weight(1f, true)) {
|
||||||
RefreshingChatroomFeedView(
|
RefreshingChatroomFeedView(
|
||||||
feedContentState = feedViewModel.feedState,
|
feedContentState = feedViewModel.feedState,
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* 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.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunityHealth
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The community-wide status strip, directly under the app bar on a Concord community's screens.
|
||||||
|
*
|
||||||
|
* It exists because the states it reports explain **what you are seeing**, and the composer's slot at
|
||||||
|
* the bottom is the wrong place to explain an empty feed. The sharpest case is stranding: a member
|
||||||
|
* left out of a CORD-06 Refounding derives every plane address from a dead root, and a dead address
|
||||||
|
* returns nothing rather than erroring — so the community renders as an ordinary quiet one, with no
|
||||||
|
* affordance anywhere that could hint otherwise.
|
||||||
|
*
|
||||||
|
* One banner at a time, by construction: [ConcordCommunityHealth] is a single value rather than a set
|
||||||
|
* of flags, so there is no way to stack two and turn this into chrome. [ConcordCommunityHealth.Healthy]
|
||||||
|
* renders nothing — not an empty row, not padding.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConcordCommunityBanner(
|
||||||
|
communityId: String,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
) {
|
||||||
|
val health by
|
||||||
|
remember(communityId, accountViewModel) { accountViewModel.account.concordHealth.flowFor(communityId) }
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val state = health) {
|
||||||
|
ConcordCommunityHealth.Healthy -> Unit
|
||||||
|
|
||||||
|
is ConcordCommunityHealth.Stranded ->
|
||||||
|
Banner(
|
||||||
|
// Recovery runs automatically on a timer, so while a way back exists the honest note is
|
||||||
|
// that it is being handled. Without an anchor it is a dead end and says so.
|
||||||
|
text =
|
||||||
|
if (state.recoverable) {
|
||||||
|
stringRes(R.string.concord_stranded_recovering)
|
||||||
|
} else {
|
||||||
|
stringRes(R.string.concord_stranded_no_anchor)
|
||||||
|
},
|
||||||
|
symbol = MaterialSymbols.Key,
|
||||||
|
container = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
|
||||||
|
is ConcordCommunityHealth.CatchingUp ->
|
||||||
|
Banner(
|
||||||
|
text = stringRes(R.string.concord_catching_up),
|
||||||
|
symbol = null,
|
||||||
|
container = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
|
||||||
|
is ConcordCommunityHealth.RecoveryFailed ->
|
||||||
|
Banner(
|
||||||
|
text =
|
||||||
|
stringRes(
|
||||||
|
when (state.reason) {
|
||||||
|
ConcordCommunityHealth.RecoveryFailed.Reason.LINK_REVOKED -> R.string.concord_recovery_failed_revoked
|
||||||
|
ConcordCommunityHealth.RecoveryFailed.Reason.LINK_EXPIRED -> R.string.concord_recovery_failed_expired
|
||||||
|
ConcordCommunityHealth.RecoveryFailed.Reason.LINK_UNREADABLE -> R.string.concord_recovery_failed_unreadable
|
||||||
|
ConcordCommunityHealth.RecoveryFailed.Reason.NO_ANCHOR -> R.string.concord_stranded_no_anchor
|
||||||
|
},
|
||||||
|
),
|
||||||
|
symbol = MaterialSymbols.ErrorOutline,
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
|
||||||
|
ConcordCommunityHealth.Dissolved ->
|
||||||
|
Banner(
|
||||||
|
text = stringRes(R.string.concord_dissolved_banner),
|
||||||
|
symbol = MaterialSymbols.Lock,
|
||||||
|
container = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
content = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A full-width strip under the app bar. A null [symbol] renders a spinner instead — the one state that
|
||||||
|
* is work in progress rather than a standing condition.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Banner(
|
||||||
|
text: String,
|
||||||
|
symbol: MaterialSymbol?,
|
||||||
|
container: Color,
|
||||||
|
content: Color,
|
||||||
|
) {
|
||||||
|
Surface(color = container, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
if (symbol != null) {
|
||||||
|
SymbolIcon(
|
||||||
|
symbol = symbol,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = content,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -345,6 +345,13 @@
|
|||||||
<string name="concord_leave_owner_warning">You created this community. Leaving does not delete it or hand it to anyone else, but it discards the owner key stored on your list — you would not be able to manage it again.</string>
|
<string name="concord_leave_owner_warning">You created this community. Leaving does not delete it or hand it to anyone else, but it discards the owner key stored on your list — you would not be able to manage it again.</string>
|
||||||
<string name="concord_edit_relays_desc">Where this community\'s encrypted planes are published and read.</string>
|
<string name="concord_edit_relays_desc">Where this community\'s encrypted planes are published and read.</string>
|
||||||
<string name="concord_dissolved_read_only">This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.</string>
|
<string name="concord_dissolved_read_only">This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.</string>
|
||||||
|
<string name="concord_dissolved_banner">This community has been dissolved. Its history stays readable, but nothing new can be posted.</string>
|
||||||
|
<string name="concord_stranded_recovering">This community rotated its keys without you, so new messages can\'t reach you. Reconnecting with your invite link…</string>
|
||||||
|
<string name="concord_stranded_no_anchor">This community rotated its keys without you, and this membership has no invite link to reconnect with. Ask a member for a new invite.</string>
|
||||||
|
<string name="concord_catching_up">Catching up — reconnecting after a key rotation…</string>
|
||||||
|
<string name="concord_recovery_failed_revoked">This community rotated its keys without you, and your invite link has been revoked. Ask a member for a new invite.</string>
|
||||||
|
<string name="concord_recovery_failed_expired">This community rotated its keys without you, and your invite link has expired. Ask a member for a new invite.</string>
|
||||||
|
<string name="concord_recovery_failed_unreadable">This community rotated its keys without you, and your invite link can no longer be read. Ask a member for a new invite.</string>
|
||||||
<string name="concord_typing_one">%1$s is typing…</string>
|
<string name="concord_typing_one">%1$s is typing…</string>
|
||||||
<string name="concord_typing_two">%1$s and %2$s are typing…</string>
|
<string name="concord_typing_two">%1$s and %2$s are typing…</string>
|
||||||
<string name="concord_typing_many">Several people are typing…</string>
|
<string name="concord_typing_many">Several people are typing…</string>
|
||||||
|
|||||||
+12
@@ -495,4 +495,16 @@ object ConcordActions {
|
|||||||
priorRoot: ByteArray,
|
priorRoot: ByteArray,
|
||||||
rootEpoch: Long,
|
rootEpoch: Long,
|
||||||
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, priorRoot, rootEpoch)
|
): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, priorRoot, rootEpoch)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [wraps] hold **every** chunk of a complete base rotation off [rootEpoch] — the guard that
|
||||||
|
* lets [openBaseRekey] returning null mean "I was excluded" rather than "the fetch is incomplete".
|
||||||
|
* See [ConcordRefounding.isCompleteRotation].
|
||||||
|
*/
|
||||||
|
fun isCompleteBaseRotation(
|
||||||
|
wraps: List<Event>,
|
||||||
|
baseRekey: GroupKey,
|
||||||
|
priorRoot: ByteArray,
|
||||||
|
rootEpoch: Long,
|
||||||
|
): Boolean = ConcordRefounding.isCompleteRotation(wraps, baseRekey, priorRoot, rootEpoch)
|
||||||
}
|
}
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* 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.amethyst.commons.model.concord
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.KmpLock
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.withLock
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is currently wrong — or being fixed — with one joined Concord community, as one value.
|
||||||
|
*
|
||||||
|
* Everything here describes the community as a whole rather than a single channel, which is why it
|
||||||
|
* belongs above the feed instead of in the composer's slot ([com.vitorpamplona.amethyst.commons.model.chats.PostingGate]
|
||||||
|
* answers the narrower "may I post here"). A dissolved or stranded community explains an *empty
|
||||||
|
* feed*, and an explanation for an empty feed cannot live below it.
|
||||||
|
*
|
||||||
|
* Deliberately one value, not a set: a screen shows at most one of these. Two stacked banners is how
|
||||||
|
* a status surface turns into chrome that users learn to scroll past.
|
||||||
|
*/
|
||||||
|
sealed interface ConcordCommunityHealth {
|
||||||
|
/** Nothing to say. Renders no banner at all. */
|
||||||
|
data object Healthy : ConcordCommunityHealth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CORD-06 Refounding happened without us: the rotation's chunks were all present at our
|
||||||
|
* next-epoch rekey address and none carried a blob for this account, so we are sitting on a dead
|
||||||
|
* root at [strandedAtEpoch] while the community moved to [newEpoch].
|
||||||
|
*
|
||||||
|
* This is the state that was previously invisible — the community simply looked quiet, because
|
||||||
|
* every plane address we derive is dead and a dead address returns nothing rather than erroring.
|
||||||
|
*
|
||||||
|
* [recoverable] is whether an automatic way back exists: the membership must carry the invite
|
||||||
|
* link it was joined through (`inviteRef`), which is the only anchor that survives a rotation we
|
||||||
|
* were not party to. Without it there is nothing to re-resolve and only a fresh invite helps.
|
||||||
|
*/
|
||||||
|
data class Stranded(
|
||||||
|
val strandedAtEpoch: Long,
|
||||||
|
val newEpoch: Long,
|
||||||
|
val recoverable: Boolean,
|
||||||
|
) : ConcordCommunityHealth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery is running: we are re-resolving the stored invite link to pick up the root we missed.
|
||||||
|
* Transient and expected — the honest thing to show while it works is progress, not an error.
|
||||||
|
*/
|
||||||
|
data class CatchingUp(
|
||||||
|
val fromEpoch: Long,
|
||||||
|
) : ConcordCommunityHealth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We were left out and cannot get back in on our own: the invite link we joined through no longer
|
||||||
|
* resolves to a live bundle ([reason] says why). A human has to send a new invite.
|
||||||
|
*/
|
||||||
|
data class RecoveryFailed(
|
||||||
|
val reason: Reason,
|
||||||
|
) : ConcordCommunityHealth {
|
||||||
|
enum class Reason {
|
||||||
|
/** The owner retired the invite link (a `vsk=9` tombstone). */
|
||||||
|
LINK_REVOKED,
|
||||||
|
|
||||||
|
/** The link's `expires_at` has passed. */
|
||||||
|
LINK_EXPIRED,
|
||||||
|
|
||||||
|
/** The bundle is unreadable — wrong token, or a newer format than we parse. */
|
||||||
|
LINK_UNREADABLE,
|
||||||
|
|
||||||
|
/** The membership carries no invite link at all, so there is nothing to re-resolve. */
|
||||||
|
NO_ANCHOR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The community was sealed read-only by an owner-signed tombstone (CORD-02 §9). Held keys still
|
||||||
|
* open the history; nothing new is honored.
|
||||||
|
*
|
||||||
|
* Also surfaced in the composer's place while a channel is open, but that only covers a channel
|
||||||
|
* you have already entered — the banner is what explains the community's own screens.
|
||||||
|
*/
|
||||||
|
data object Dissolved : ConcordCommunityHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-community [ConcordCommunityHealth], written by the account's rekey drain and recovery sweep and
|
||||||
|
* read by the UI.
|
||||||
|
*
|
||||||
|
* Held outside `ConcordCommunitySession` on purpose: recovery outcomes outlive any one session
|
||||||
|
* instance (a successful merge replaces the entry and rebuilds the session, and a banner reporting
|
||||||
|
* that must survive that rebuild), and a community that failed to recover may have no working session
|
||||||
|
* at all.
|
||||||
|
*/
|
||||||
|
class ConcordCommunityHealthState {
|
||||||
|
private val lock = KmpLock()
|
||||||
|
private val flows = HashMap<HexKey, MutableStateFlow<ConcordCommunityHealth>>()
|
||||||
|
|
||||||
|
/** This community's live health. Creating the flow on read lets the UI subscribe before any write. */
|
||||||
|
fun flowFor(communityId: HexKey): StateFlow<ConcordCommunityHealth> = flowForInternal(communityId)
|
||||||
|
|
||||||
|
fun currentFor(communityId: HexKey): ConcordCommunityHealth = flowForInternal(communityId).value
|
||||||
|
|
||||||
|
fun set(
|
||||||
|
communityId: HexKey,
|
||||||
|
health: ConcordCommunityHealth,
|
||||||
|
) {
|
||||||
|
flowForInternal(communityId).value = health
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears a community back to [ConcordCommunityHealth.Healthy], but **only** if it currently holds
|
||||||
|
* [ifCurrently]. Used to retract a transient state (a catch-up that finished) without stomping a
|
||||||
|
* more important one that landed meanwhile.
|
||||||
|
*/
|
||||||
|
fun clearIf(
|
||||||
|
communityId: HexKey,
|
||||||
|
ifCurrently: (ConcordCommunityHealth) -> Boolean,
|
||||||
|
) {
|
||||||
|
val flow = flowForInternal(communityId)
|
||||||
|
if (ifCurrently(flow.value)) flow.value = ConcordCommunityHealth.Healthy
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() =
|
||||||
|
lock.withLock {
|
||||||
|
flows.values.forEach { it.value = ConcordCommunityHealth.Healthy }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flowForInternal(communityId: HexKey): MutableStateFlow<ConcordCommunityHealth> =
|
||||||
|
lock.withLock {
|
||||||
|
flows.getOrPut(communityId) { MutableStateFlow(ConcordCommunityHealth.Healthy) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* 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.amethyst.commons.model.concord
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-community banner state: one value at a time, and retractions that can't stomp a more
|
||||||
|
* important state that landed meanwhile.
|
||||||
|
*
|
||||||
|
* The single-value shape is the point. Stranding, dissolution and a dead invite link all explain the
|
||||||
|
* same thing — why the feed is empty or frozen — and stacking two banners is how a status surface
|
||||||
|
* becomes chrome users scroll past.
|
||||||
|
*/
|
||||||
|
class ConcordCommunityHealthTest {
|
||||||
|
private val alpha = "a1".repeat(32)
|
||||||
|
private val beta = "b2".repeat(32)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aCommunityStartsHealthyAndSubscribingDoesNotChangeThat() {
|
||||||
|
val state = ConcordCommunityHealthState()
|
||||||
|
assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
|
||||||
|
assertEquals(ConcordCommunityHealth.Healthy, state.flowFor(alpha).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theFlowIsStableAcrossReadsSoTheUiCanSubscribeBeforeAnyWrite() {
|
||||||
|
// The banner collects on first composition, which routinely happens before the rekey drain
|
||||||
|
// has anything to say; both sides must share one flow instance or the update never arrives.
|
||||||
|
val state = ConcordCommunityHealthState()
|
||||||
|
val first = state.flowFor(alpha)
|
||||||
|
state.set(alpha, ConcordCommunityHealth.Dissolved)
|
||||||
|
|
||||||
|
assertSame(first, state.flowFor(alpha))
|
||||||
|
assertEquals(ConcordCommunityHealth.Dissolved, first.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun communitiesAreIndependent() {
|
||||||
|
val state = ConcordCommunityHealthState()
|
||||||
|
state.set(alpha, ConcordCommunityHealth.Stranded(strandedAtEpoch = 3, newEpoch = 4, recoverable = true))
|
||||||
|
|
||||||
|
assertTrue(state.currentFor(alpha) is ConcordCommunityHealth.Stranded)
|
||||||
|
assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(beta))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFinishedCatchUpRetractsItselfWithoutClearingSomethingWorse() {
|
||||||
|
val state = ConcordCommunityHealthState()
|
||||||
|
state.set(alpha, ConcordCommunityHealth.CatchingUp(fromEpoch = 3))
|
||||||
|
|
||||||
|
// The recovery that started the catch-up finished: retract only the catch-up.
|
||||||
|
state.clearIf(alpha) { it is ConcordCommunityHealth.CatchingUp }
|
||||||
|
assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
|
||||||
|
|
||||||
|
// Now the same retraction runs when the community has since been dissolved. Dissolution is
|
||||||
|
// terminal and was observed independently, so a late "recovery finished" must not erase it.
|
||||||
|
state.set(alpha, ConcordCommunityHealth.Dissolved)
|
||||||
|
state.clearIf(alpha) { it is ConcordCommunityHealth.CatchingUp }
|
||||||
|
assertEquals(ConcordCommunityHealth.Dissolved, state.currentFor(alpha))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun adoptingARotationClearsEverythingExceptDissolution() {
|
||||||
|
// What the rekey drain does after successfully adopting a new root.
|
||||||
|
val state = ConcordCommunityHealthState()
|
||||||
|
|
||||||
|
state.set(alpha, ConcordCommunityHealth.Stranded(3, 4, recoverable = true))
|
||||||
|
state.clearIf(alpha) { it !is ConcordCommunityHealth.Dissolved }
|
||||||
|
assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
|
||||||
|
|
||||||
|
state.set(alpha, ConcordCommunityHealth.RecoveryFailed(ConcordCommunityHealth.RecoveryFailed.Reason.LINK_REVOKED))
|
||||||
|
state.clearIf(alpha) { it !is ConcordCommunityHealth.Dissolved }
|
||||||
|
assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
|
||||||
|
|
||||||
|
state.set(alpha, ConcordCommunityHealth.Dissolved)
|
||||||
|
state.clearIf(alpha) { it !is ConcordCommunityHealth.Dissolved }
|
||||||
|
assertEquals(ConcordCommunityHealth.Dissolved, state.currentFor(alpha))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun strandingRecordsBothEpochsAndWhetherAWayBackExists() {
|
||||||
|
// recoverable=false is the dead end: no stored invite link means nothing to re-resolve, so the
|
||||||
|
// copy has to ask for a new invite rather than promise a reconnect.
|
||||||
|
val recoverable = ConcordCommunityHealth.Stranded(strandedAtEpoch = 7, newEpoch = 8, recoverable = true)
|
||||||
|
assertEquals(7, recoverable.strandedAtEpoch)
|
||||||
|
assertEquals(8, recoverable.newEpoch)
|
||||||
|
|
||||||
|
val dead = ConcordCommunityHealth.Stranded(strandedAtEpoch = 7, newEpoch = 8, recoverable = false)
|
||||||
|
assertEquals(false, dead.recoverable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearResetsEveryKnownCommunity() {
|
||||||
|
val state = ConcordCommunityHealthState()
|
||||||
|
state.set(alpha, ConcordCommunityHealth.Dissolved)
|
||||||
|
state.set(beta, ConcordCommunityHealth.Stranded(1, 2, recoverable = false))
|
||||||
|
|
||||||
|
state.clear()
|
||||||
|
|
||||||
|
assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(alpha))
|
||||||
|
assertEquals(ConcordCommunityHealth.Healthy, state.currentFor(beta))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ to hold the bit **and strictly outrank** the target ("equal cannot act on equal"
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Dissolved** | owner-only `DISSOLVED` tombstone (CORD-02 §9) | Terminal, one-way. The community seals **read-only for everyone including the owner**: held keys still open history, nothing new is honored. `ConcordChannel.canPost() = membership.isMember() && !dissolved`. The one carve-out — deleting your own past message — runs through the note menu, not the composer, so this gate never blocks it. |
|
| **Dissolved** | owner-only `DISSOLVED` tombstone (CORD-02 §9) | Terminal, one-way. The community seals **read-only for everyone including the owner**: held keys still open history, nothing new is honored. `ConcordChannel.canPost() = membership.isMember() && !dissolved`. The one carve-out — deleting your own past message — runs through the note menu, not the composer, so this gate never blocks it. |
|
||||||
| **Current epoch** | `rootEpoch` + `heldRoots` on the kind-13302 entry | Which epochs' history you can still derive. Prior-epoch roots are kept so pre-Refounding messages stay readable. |
|
| **Current epoch** | `rootEpoch` + `heldRoots` on the kind-13302 entry | Which epochs' history you can still derive. Prior-epoch roots are kept so pre-Refounding messages stay readable. |
|
||||||
| **Stranded / excluded** | `excludedAtEpoch`, detected by `ConcordStrandedRecovery` | A Refounding carries **no recipient list**, so a member simply left out hears nothing and sits on a dead epoch forever. The only way back is re-resolving the membership's own `inviteRef` and finding a higher epoch — then `mergeForward` catches up while preserving the anchor and prior roots. |
|
| **Stranded / excluded** | Detected live in the rekey drain (`isCompleteBaseRotation` + no blob for us); `excludedAtEpoch` records it after a merge | A Refounding carries **no recipient list**, but the rekey wraps sit at an address every current member derives, so an excluded member *can* see the rotation happen and find no blob addressed to them — which is the detection. The way back is re-resolving the membership's own `inviteRef` and finding a higher epoch; `mergeForward` then catches up while preserving the anchor and prior roots. |
|
||||||
| **Refounded out** | not receiving the new root | The only *real* eviction Concord has: cryptographic, not a flag. |
|
| **Refounded out** | not receiving the new root | The only *real* eviction Concord has: cryptographic, not a flag. |
|
||||||
| **Kicked** | kind **3309** Guestbook kick, honored only from a `KICK` holder who outranks the target | Off-consensus and advisory. **Modeled in Quartz, not wired in the client** — nothing calls `Guestbook.kick`/`kickTarget`. |
|
| **Kicked** | kind **3309** Guestbook kick, honored only from a `KICK` holder who outranks the target | Off-consensus and advisory. **Modeled in Quartz, not wired in the client** — nothing calls `Guestbook.kick`/`kickTarget`. |
|
||||||
| **Guestbook joined / left** | self-signed kind **3306** `join`/`leave` | Best-effort presence for member discovery, explicitly *not* authority. |
|
| **Guestbook joined / left** | self-signed kind **3306** `join`/`leave` | Best-effort presence for member discovery, explicitly *not* authority. |
|
||||||
@@ -239,7 +239,7 @@ others), and **invisible** (nothing renders it at all).
|
|||||||
| My own `OWNER`/`ADMIN` | only via affordances: the create-channel FAB, the Edit icon, the roster's promote/ban items | implied |
|
| My own `OWNER`/`ADMIN` | only via affordances: the create-channel FAB, the Edit icon, the roster's promote/ban items | implied |
|
||||||
| My own `BANNED` | `PostingGateNotice` takes the composer's place and names the ban ("Past messages stay readable, but you can no longer post"). Was invisible: the only explanatory branch tested `dissolved`, so a banned member got an empty space and no reason. | **shown** |
|
| My own `BANNED` | `PostingGateNotice` takes the composer's place and names the ban ("Past messages stay readable, but you can no longer post"). Was invisible: the only explanatory branch tested `dissolved`, so a banned member got an empty space and no reason. | **shown** |
|
||||||
| `dissolved` | The same `PostingGateNotice` replaces the composer, inside an opened channel only — not on the community row, the channel list, or the Messages inbox. (There is also **no write path** to dissolve: an owner cannot do it from Amethyst.) | partial |
|
| `dissolved` | The same `PostingGateNotice` replaces the composer, inside an opened channel only — not on the community row, the channel list, or the Messages inbox. (There is also **no write path** to dissolve: an owner cannot do it from Amethyst.) | partial |
|
||||||
| **stranded / excluded epoch** | nothing. `recoverStrandedConcordCommunities()` runs at startup, `Log.w` on failure. A stranded community renders as an ordinary *empty* one. | **invisible** |
|
| **stranded / excluded epoch** | `ConcordCommunityBanner` under the app bar on both the channel list and an open channel, off `ConcordCommunityHealth`: reconnecting / caught up / a dead invite link naming which kind of dead. Detected directly at the rekey address — the rotation's chunks are all there and none carries our blob — not inferred from silence. | **shown** |
|
||||||
| current epoch / `heldRoots` | nothing | **invisible** |
|
| current epoch / `heldRoots` | nothing | **invisible** |
|
||||||
| `private` channel | `Lock` icon in the channel list, shown only to members who hold its key; others get no row (and no inbox row). Reaching one anyway explains itself via `PostingGate.NoKey`. | **shown** |
|
| `private` channel | `Lock` icon in the channel list, shown only to members who hold its key; others get no row (and no inbox row). Reaching one anyway explains itself via `PostingGate.NoKey`. | **shown** |
|
||||||
| `voice` channel | `Mic` icon + preview-line suppression in the list; the opened channel has no voice affordance | partial |
|
| `voice` channel | `Mic` icon + preview-line suppression in the list; the opened channel has no voice affordance | partial |
|
||||||
@@ -316,8 +316,16 @@ UI-only (the state is tracked correctly, nothing tells the user):
|
|||||||
says a resend could work — `rate-limited`, `error`, `auth-required` — not for `blocked` /
|
says a resend could work — `rate-limited`, `error`, `auth-required` — not for `blocked` /
|
||||||
`restricted`, where the relay would repeat itself. This is a general fix: every relay refusal
|
`restricted`, where the relay would repeat itself. This is a general fix: every relay refusal
|
||||||
on a chat message now reaches the sender, with a ban as one case.
|
on a chat message now reaches the sender, with a ban as one case.
|
||||||
8. **Stranding is silent.** Recovery is automatic and log-only, so a member left out of a
|
8. ~~**Stranding is silent.**~~ **Fixed.** Two things were missing, not one. *Detection:* the rekey
|
||||||
Refounding sees an ordinary empty community rather than "you were left behind on epoch N".
|
drain treated "no blob for me" as nothing to do, when the rekey wraps sit at an address every
|
||||||
|
current member derives — so an excluded member can watch the rotation happen and find no blob
|
||||||
|
addressed to them. That is now the signal, gated on `ConcordRefounding.isCompleteRotation` so a
|
||||||
|
partial fetch (a relay cap, a dropped socket) can't be mistaken for exclusion. *Reporting:*
|
||||||
|
`ConcordCommunityHealth` per community, rendered by `ConcordCommunityBanner` above the feed —
|
||||||
|
stranded / catching up / recovery failed, plus dissolution, which the composer notice could only
|
||||||
|
show to someone already inside a channel. One value, not a set, so two banners cannot stack. The
|
||||||
|
recovery sweep's four collapsed `continue`s now distinguish revoked / expired / unreadable /
|
||||||
|
no-anchor, and a dead link is only reported once it is actually the thing blocking us.
|
||||||
9. **Buzz tenant moderation has no write path.** 9031 remove-member has no caller; 9032
|
9. **Buzz tenant moderation has no write path.** 9031 remove-member has no caller; 9032
|
||||||
change-role, 9040/9041 ban/unban and 9042/9043 timeout/untimeout have no builder call at
|
change-role, 9040/9041 ban/unban and 9042/9043 timeout/untimeout have no builder call at
|
||||||
all. Only 9030 add-member is reachable (the "Add people" dialog).
|
all. Only 9030 add-member is reachable (the "Add people" dialog).
|
||||||
|
|||||||
+46
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
|||||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.fastFirstOrNull
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
@@ -166,6 +167,51 @@ object ConcordRefounding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [wraps] contain **every chunk** of a complete base rotation to `rootEpoch + 1` that
|
||||||
|
* continues the [priorRoot] this member holds.
|
||||||
|
*
|
||||||
|
* This is the guard that turns [findNewRoot] returning null into a conclusion. A rotation's
|
||||||
|
* recipient blobs are chunked ([ConcordRekey.MAX_BLOBS_PER_CHUNK] each), so on a partial fetch —
|
||||||
|
* a relay result cap, a dropped socket, a chunk still in flight — the absence of this member's
|
||||||
|
* blob proves nothing. Only once chunk indices `0 until total` are all present does "my blob is
|
||||||
|
* not here" mean "I was left out".
|
||||||
|
*
|
||||||
|
* Returns false when no matching chunk is present at all: nothing was rotated as far as we know,
|
||||||
|
* which is different from being excluded from something that was.
|
||||||
|
*/
|
||||||
|
fun isCompleteRotation(
|
||||||
|
wraps: List<Event>,
|
||||||
|
baseRekeyKey: GroupKey,
|
||||||
|
priorRoot: ByteArray,
|
||||||
|
rootEpoch: Long,
|
||||||
|
): Boolean {
|
||||||
|
val newEpoch = rootEpoch + 1
|
||||||
|
val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey()
|
||||||
|
val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey()
|
||||||
|
|
||||||
|
var total = -1
|
||||||
|
val seen = HashSet<Int>()
|
||||||
|
for (wrap in wraps) {
|
||||||
|
val rumor = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey)?.rumor ?: continue
|
||||||
|
if (rumor.kind != ConcordRekey.KIND) continue
|
||||||
|
if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE) != expectedScope) continue
|
||||||
|
if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue
|
||||||
|
if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT) != expectedCommit) continue
|
||||||
|
|
||||||
|
val chunk = rumor.tags.fastFirstOrNull { it.size >= 3 && it[0] == ConcordRekey.TAG_CHUNK } ?: continue
|
||||||
|
val index = chunk[1].toIntOrNull() ?: continue
|
||||||
|
val chunkTotal = chunk[2].toIntOrNull() ?: continue
|
||||||
|
if (chunkTotal <= 0 || index < 0 || index >= chunkTotal) continue
|
||||||
|
// Chunks of one rotation agree on the total; a disagreement means we are looking at
|
||||||
|
// mixed or malformed input, so claim nothing.
|
||||||
|
if (total != -1 && total != chunkTotal) return false
|
||||||
|
total = chunkTotal
|
||||||
|
seen.add(index)
|
||||||
|
}
|
||||||
|
return total > 0 && seen.size == total
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Receives a base rotation for the member behind [recipientSigner]: opens the
|
* Receives a base rotation for the member behind [recipientSigner]: opens the
|
||||||
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
|
* kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]),
|
||||||
|
|||||||
+89
@@ -37,6 +37,7 @@ import kotlinx.coroutines.test.runTest
|
|||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertContentEquals
|
import kotlin.test.assertContentEquals
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
@@ -89,6 +90,94 @@ class ConcordRefoundingTest {
|
|||||||
assertNull(carolRoot) // removed member receives no blob
|
assertNull(carolRoot) // removed member receives no blob
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guard that lets a client conclude it was excluded. [ConcordRefounding.findNewRoot] returning
|
||||||
|
* null is ambiguous on its own — "no blob for me" and "I haven't fetched the chunk my blob is in"
|
||||||
|
* look identical — so exclusion may only be announced once every chunk of the rotation is in hand.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aCompleteRotationIsWhatMakesExclusionProvable() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val priorRoot = community.communityRoot
|
||||||
|
val build =
|
||||||
|
ConcordRefounding.build(
|
||||||
|
rotatorSigner = owner,
|
||||||
|
communityId = community.communityId,
|
||||||
|
priorRoot = priorRoot,
|
||||||
|
newRoot = newRoot,
|
||||||
|
rootEpoch = community.rootEpoch,
|
||||||
|
priorControlWraps = community.genesisWraps,
|
||||||
|
priorControlKey = community.controlPlane,
|
||||||
|
recipientsXOnly = listOf(alice.pubKey, bob.pubKey),
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, community.communityId, build.newEpoch)
|
||||||
|
|
||||||
|
// Carol holds the whole rotation and finds no blob: she was left out, and can say so.
|
||||||
|
assertTrue(ConcordRefounding.isCompleteRotation(build.rekeyWraps, baseRekeyKey, priorRoot, community.rootEpoch))
|
||||||
|
assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, carol, priorRoot, community.rootEpoch))
|
||||||
|
|
||||||
|
// Having fetched nothing looks the same to findNewRoot, and must NOT read as exclusion.
|
||||||
|
assertFalse(ConcordRefounding.isCompleteRotation(emptyList(), baseRekeyKey, priorRoot, community.rootEpoch))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRotationOffADifferentPriorRootOrEpochDoesNotCount() =
|
||||||
|
runTest {
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val build =
|
||||||
|
ConcordRefounding.build(
|
||||||
|
rotatorSigner = owner,
|
||||||
|
communityId = community.communityId,
|
||||||
|
priorRoot = community.communityRoot,
|
||||||
|
newRoot = newRoot,
|
||||||
|
rootEpoch = community.rootEpoch,
|
||||||
|
priorControlWraps = community.genesisWraps,
|
||||||
|
priorControlKey = community.controlPlane,
|
||||||
|
recipientsXOnly = listOf(alice.pubKey),
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch)
|
||||||
|
|
||||||
|
// Same wraps, but checked as though we held a different root: the prevcommit no longer
|
||||||
|
// continues ours, so this is not a rotation of the epoch we are on.
|
||||||
|
val otherRoot = ByteArray(32) { 0x11 }
|
||||||
|
assertFalse(ConcordRefounding.isCompleteRotation(build.rekeyWraps, baseRekeyKey, otherRoot, community.rootEpoch))
|
||||||
|
// And checked at the wrong epoch, the newepoch tag no longer matches.
|
||||||
|
assertFalse(ConcordRefounding.isCompleteRotation(build.rekeyWraps, baseRekeyKey, community.communityRoot, community.rootEpoch + 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRotationMissingOneOfItsChunksIsIncomplete() =
|
||||||
|
runTest {
|
||||||
|
// Enough recipients to force chunking, so a dropped chunk is a realistic partial fetch.
|
||||||
|
val extras = List(ConcordRekey.MAX_BLOBS_PER_CHUNK) { NostrSignerInternal(KeyPair()).pubKey }
|
||||||
|
val community = ConcordCommunityFactory.create(owner, "Test", now)
|
||||||
|
val priorRoot = community.communityRoot
|
||||||
|
val build =
|
||||||
|
ConcordRefounding.build(
|
||||||
|
rotatorSigner = owner,
|
||||||
|
communityId = community.communityId,
|
||||||
|
priorRoot = priorRoot,
|
||||||
|
newRoot = newRoot,
|
||||||
|
rootEpoch = community.rootEpoch,
|
||||||
|
priorControlWraps = community.genesisWraps,
|
||||||
|
priorControlKey = community.controlPlane,
|
||||||
|
recipientsXOnly = listOf(alice.pubKey) + extras,
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, community.communityId, build.newEpoch)
|
||||||
|
|
||||||
|
assertTrue(build.rekeyWraps.size > 1, "expected the rotation to be chunked")
|
||||||
|
assertTrue(ConcordRefounding.isCompleteRotation(build.rekeyWraps, baseRekeyKey, priorRoot, community.rootEpoch))
|
||||||
|
|
||||||
|
// Drop the chunk Alice's blob happens to be in and she looks excluded — which is exactly
|
||||||
|
// why the completeness check has to gate that conclusion.
|
||||||
|
val partial = build.rekeyWraps.drop(1)
|
||||||
|
assertFalse(ConcordRefounding.isCompleteRotation(partial, baseRekeyKey, priorRoot, community.rootEpoch))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun compactedControlPlaneFoldsIdenticallyUnderNewRoot() =
|
fun compactedControlPlaneFoldsIdenticallyUnderNewRoot() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
Reference in New Issue
Block a user