diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 941368763b..171f539537 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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.concord.ConcordChannel 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.emphChat.EphemeralChatChannel 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 // 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. + // 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() for (channelIdHex in state.channels.keys) { val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) @@ -810,6 +817,13 @@ class Account( // own chat bubbles. 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 marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) } @@ -2976,14 +2990,37 @@ class Account( val wraps = session.pendingBaseRekeyWraps() if (wraps.isEmpty()) continue val entry = session.entry + val baseRekey = session.nextBaseRekeyKey() + val priorRoot = entry.root.hexToByteArray() val received = ConcordActions.openBaseRekey( wraps = wraps, - baseRekey = session.nextBaseRekeyKey(), + baseRekey = baseRekey, recipientSigner = signer, - priorRoot = entry.root.hexToByteArray(), + priorRoot = priorRoot, 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 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) if (!authorized) continue 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 val now = TimeUtils.nowMillis() 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] if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue 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 = ( parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + @@ -3043,16 +3109,49 @@ class Account( ).toSet() 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 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 + // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. Which + // 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 Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}") sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged)) announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) + // Caught up: the banner has nothing left to report. + concordHealth.clearIf(entry.id) { it !is ConcordCommunityHealth.Dissolved } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 1fe3ba646f..2be266c56d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -307,62 +307,68 @@ fun ConcordChannelListScreen( } }, ) { padding -> - // Only channels this account can open. A private channel whose per-member key was never - // 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. - val channels = - remember(state, session, revision) { - val folded = state?.channels.orEmpty() - session?.openableChannelIds().orEmpty().mapNotNull { id -> folded[id]?.let { id to it } } - } - if (channels.isEmpty()) { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { - Text( - stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } 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().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) }, + // Community-wide status first: stranding and dissolution explain why the list below may be + // empty or frozen, so they belong above it rather than inside a channel. + Column(Modifier.padding(padding)) { + ConcordCommunityBanner(communityId, accountViewModel) + + // Only channels this account can open. A private channel whose per-member key was never + // 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. + val channels = + remember(state, session, revision) { + val folded = state?.channels.orEmpty() + session?.openableChannelIds().orEmpty().mapNotNull { id -> folded[id]?.let { id to it } } + } + if (channels.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - 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) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 5c8338424b..a2253b3102 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -211,6 +211,10 @@ fun ConcordChannelScreen( .consumeWindowInsets(padding) .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)) { RefreshingChatroomFeedView( feedContentState = feedViewModel.feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityBanner.kt new file mode 100644 index 0000000000..a8caf0c562 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityBanner.kt @@ -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, + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 73ae261bd1..0e5192df99 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -345,6 +345,13 @@ 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. Where this community\'s encrypted planes are published and read. This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted. + This community has been dissolved. Its history stays readable, but nothing new can be posted. + This community rotated its keys without you, so new messages can\'t reach you. Reconnecting with your invite link… + This community rotated its keys without you, and this membership has no invite link to reconnect with. Ask a member for a new invite. + Catching up — reconnecting after a key rotation… + This community rotated its keys without you, and your invite link has been revoked. Ask a member for a new invite. + This community rotated its keys without you, and your invite link has expired. Ask a member for a new invite. + This community rotated its keys without you, and your invite link can no longer be read. Ask a member for a new invite. %1$s is typing… %1$s and %2$s are typing… Several people are typing… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index dcf224e512..2a6ee1580d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -495,4 +495,16 @@ object ConcordActions { priorRoot: ByteArray, rootEpoch: Long, ): 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, + baseRekey: GroupKey, + priorRoot: ByteArray, + rootEpoch: Long, + ): Boolean = ConcordRefounding.isCompleteRotation(wraps, baseRekey, priorRoot, rootEpoch) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealth.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealth.kt new file mode 100644 index 0000000000..d1ea2154a8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealth.kt @@ -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>() + + /** This community's live health. Creating the flow on read lets the UI subscribe before any write. */ + fun flowFor(communityId: HexKey): StateFlow = 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 = + lock.withLock { + flows.getOrPut(communityId) { MutableStateFlow(ConcordCommunityHealth.Healthy) } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealthTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealthTest.kt new file mode 100644 index 0000000000..469326541e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunityHealthTest.kt @@ -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)) + } +} diff --git a/docs/community-status-matrix.md b/docs/community-status-matrix.md index 2638284423..6bff935978 100644 --- a/docs/community-status-matrix.md +++ b/docs/community-status-matrix.md @@ -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. | | **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. | | **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. | @@ -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 `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 | -| **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** | | `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 | @@ -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` / `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. -8. **Stranding is silent.** Recovery is automatic and log-only, so a member left out of a - Refounding sees an ordinary empty community rather than "you were left behind on epoch N". +8. ~~**Stranding is silent.**~~ **Fixed.** Two things were missing, not one. *Detection:* the rekey + 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 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). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt index 7a7bb34899..7e1661cc69 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event 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.hexToByteArray 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, + 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() + 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 * kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]), diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt index fa919e9b14..5cf63e8b93 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -89,6 +90,94 @@ class ConcordRefoundingTest { 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 fun compactedControlPlaneFoldsIdenticallyUnderNewRoot() = runTest {