From de3b3556000c2d7f1cbdefd61631d4278a30818b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:08:17 +0200 Subject: [PATCH 1/3] feat: hand back a room's running ceremony rather than opening a second proposeRitual minted a fresh session on every call. Nothing called it twice for the same room, so nothing went wrong: the only way in was the shared-key screen, and canStartRitual() returns false while a session exists that has not failed. That is about to stop being true. A robust group opens a ceremony as it is created, and a NIP-17 room id is derived from its member set -- so making the same group again returns the same room and asks it again. The guard also sat in the wrong place regardless: on an observed UI snapshot, a screen away from the write it was protecting. ## What a second proposal costs It is not a duplicate row. ChillDKG hashes the participant set and the threshold into the session identity, so a second ceremony over the same room is a second `n` and `t` for every member to reconcile, and members join whichever proposal reaches them first -- relays hand gift wraps back in no particular order, so which one that is differs per device. The group ends up split across two ceremonies, neither of which can assemble the participant count it needs. Worse if the first one had finished. FrostSigningManager.completedKey falls back to getLatestSessionForChatRoom when the room has no signed key state and its id is not derived from the threshold key; a newer, unfinished session shadows the completed one there, and the group stops being able to reach the key it actually holds. ## The rule, and where it now lives The room's live ritual is returned as-is, so a caller gets a session either way and cannot tell whether it opened one. That is what makes the creation path safe to re-enter. The rule itself is unchanged -- it is the one canStartRitual() has always applied, right down to which stages block. It now also lives next to the write, where a stale snapshot cannot race it. FAILED is excluded deliberately: it is the one stage that does not hold the room's slot. A collapsed ceremony leaves the group with no key and a room they can still talk in, which is exactly the group that should be able to try again. Every other stage, COMPLETE included, is a ceremony the room depends on the outcome of. Checked before the require()s rather than after. A running ceremony settled the threshold question when it opened, so validating the argument would be validating an input with no effect -- and it would turn re-entering with a different quorum into an exception instead of the ceremony that exists. Co-Authored-By: Claude Opus 5 --- .../compose/managers/ChillDkgRitualManager.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index cf20fd29..93386f5a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -110,7 +110,8 @@ object ChillDkgRitualManager { memberPublicKeys(localChatRoom).size >= MINIMUM_PARTICIPANTS /** - * Opens a ritual, making this device the coordinator. + * Opens a ritual, making this device the coordinator. A room already running + * one gets that one back rather than a second. * * Throws when the group or the threshold cannot support one; the UI checks * both before offering the button, so reaching either is a bug rather than a @@ -123,6 +124,26 @@ object ChillDkgRitualManager { nostrPrivateKey: ByteArray, threshold: Int ): DkgSession { + // A room runs one ceremony at a time, and this is reachable twice now that + // a robust group opens one as it is created: the room id is derived from + // its members, so making the same group again lands back in the same room + // and asks again. A second proposal is a second participant set for every + // member to reconcile, and the key the first one produced would be left + // with nothing pointing at it. A failed ritual is not running, and is + // there to be replaced. + // + // Checked before the arguments are, because a running ritual makes the + // requested threshold moot -- it settled that question when it opened. + database.dkgSessionDao().getLatestSessionForChatRoom(localChatRoom.chatRoom.id) + ?.takeIf { it.stage != DkgRitualStage.FAILED } + ?.let { running -> + logger.i( + "Room ${localChatRoom.chatRoom.id} is already running ritual " + + "${running.id}; not opening another" + ) + return running + } + // Built the way a receiver rebuilds it from the proposal — the p-tags // `broadcast` writes, plus this device — so both sides count the same `n` // even if the room's own rows have drifted. From 0c240a31c81467179f546e4e7c607e8a1830e5e7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:08:44 +0200 Subject: [PATCH 2/3] feat: open a robust group's key ceremony as it is created Picking "robust" made a NIP-17 room and left it at that. The quorum the user had just set was read, explained, coerced into range -- and then dropped on the floor, with a TODO where it should have gone saying so: NIP-17 has no group state to change and so nothing to approve, and a different member set is simply a different room. That TODO had an answer the app has been able to give since ChillDkgRitual- Manager landed. The one thing a t-of-n rule can attach to here is a key the members generate together and cannot sign with unless t of n of them are present, and everything a ceremony needs is settled the moment the room exists: who is in it, and how many of them have to agree. So the room now opens one, and the proposal is its first message. ## Why at creation rather than behind the button The button is still there on the shared-key screen, and this changes nothing about it. What it cannot do is be found. A group that picked robust and got a plain NIP-17 room has the thing that makes it robust sitting one unmarked navigation away, and until somebody takes it the group's governance is a number nobody enforces. It is also how the rest of the group hears of the room at all. Standing up a NIP-17 room sends nothing to anybody -- there is no invite, no welcome, no key package -- so before this the first anyone learned of a robust group was whenever somebody happened to type into it. The proposal is now the first event out, and NostrDao already builds the room on the receiving side from a DKG payload's p-tags for exactly this reason. ## The order this runs in The room is created first, then the ceremony is proposed, then the screen navigates. createdChatRoomId is set the moment the room exists, before the proposal, so a second tap reuses that room rather than minting another -- and it is what freezes the type and quorum pickers, both of which are answered by then. Proposing before navigating means the chat opens with the ceremony already in it rather than filling in underneath the user. It costs no round trip: proposeRitual writes rows and queues a gift-wrap payload, and NotaryViewModel seals and broadcasts on its own schedule. The quorum is passed through as the threshold with no coercion. The screen derives its range from the picked members plus the creator, and createNip17ChatRoom stores exactly that set as the room's participants, so the range proposeRitual validates against is the same one the picker was bounded by. ## When the ceremony does not open Nothing is rolled back. The room is real, the group can talk in it, and the ceremony can be opened later from the group's details -- so failing the whole creation would be throwing away the part that worked. But it is not navigated past either. The screen stays put and says what happened, the way it already does when a Marmot group is created without some of its members; the button flips to "Open chat", which is what the user is left with. A robust group quietly without a key is the one outcome here worth interrupting for. ## Elsewhere The robust card's footnote now says that creating the group starts a key ceremony every member takes part in. Members are about to be asked to approve joining it, contributing to the key, and confirming the result, and none of that should be the first they hear of it. MantraNavHost hands the screen the DkgRepository it already builds for the ritual and approval routes; the preview takes the no-op. Left alone: DkgRitualViewModel still cannot read back the quorum a room was created with, because ChatRoom does not persist it. Its threshold picker re-derives a majority default, which now only matters for rooms made before this change or after a failed ceremony. Co-Authored-By: Claude Opus 5 --- .../ui/composable/SelectChatRoomTypeScreen.kt | 29 ++++++- .../ui/composable/navigation/MantraNavHost.kt | 1 + .../view/model/SelectChatRoomTypeViewModel.kt | 79 +++++++++++++++---- 3 files changed, 93 insertions(+), 16 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 27131b7b..3d840986 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -70,6 +71,7 @@ fun SelectChatRoomTypeScreen( activeWalletStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, + dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit, ) { val selectChatRoomTypeViewModel: SelectChatRoomTypeViewModel = viewModel( @@ -81,7 +83,8 @@ fun SelectChatRoomTypeScreen( initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, activeWalletStateFlow = activeWalletStateFlow, nostrRepository = nostrRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + dkgRepository = dkgRepository ) ) @@ -104,6 +107,7 @@ fun SelectChatRoomTypeScreen( val isActionPending = selectChatRoomTypeViewModel.isActionPending.value val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded + val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value val adminCount = selectChatRoomTypeViewModel.adminCount val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST @@ -188,6 +192,22 @@ fun SelectChatRoomTypeScreen( } } + if (keyCeremonyNotStarted) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text( + modifier = Modifier.padding(15.dp), + text = "$name was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.", + style = MaterialTheme.typography.bodyMedium + ) + } + } + Text( text = "This decides who can change the group later. You can't switch afterwards.", style = MaterialTheme.typography.labelMedium, @@ -216,7 +236,11 @@ fun SelectChatRoomTypeScreen( } else { "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect." }, - footnote = "No single admin can change the group alone, and the group outlives any one of you.", + // Says what tapping create actually does, because it is the one + // thing here that asks something of everybody else: the group's + // first act is a ceremony each member has to approve their way + // through before there is a key. + footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.", isSelected = isRobustSelected, isEnabled = isRobustAvailable, disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, @@ -447,6 +471,7 @@ private fun SelectChatRoomTypeScreenPreview() { activeWalletStateFlow = MutableStateFlow(null), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, onNavigateToRoute = {} ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 9332b751..b72d072c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -543,6 +543,7 @@ fun MantraNavHost( activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, onNavigateToRoute = { chatRoomResultRoute -> navController.navigate( chatRoomResultRoute diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt index 038cd51f..fccb41f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -15,6 +15,7 @@ import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute @@ -59,6 +60,7 @@ class SelectChatRoomTypeViewModel( val activeWalletStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, + val dkgRepository: DkgRepository, ): ViewModel() { var selectChatRoomTypeUIState: SelectChatRoomTypeUIState by mutableStateOf(initialSelectChatRoomTypeUIState) @@ -81,6 +83,15 @@ class SelectChatRoomTypeViewModel( /** Members the group was created without, because no key package ever showed up. */ val membersNotAdded = mutableStateListOf() + /** + * Set when a [ChatRoomType.ROBUST] room was made but its key ceremony was not. + * + * The room is real and usable either way -- only the shared key is missing, and + * it can be started again from the group's details. Worth saying rather than + * navigating on, because the key is the whole of what robust means. + */ + val keyCeremonyNotStarted: MutableState = mutableStateOf(false) + /** * Everyone who administers the group under [ChatRoomType.ROBUST]: the picked * members plus the creator. @@ -188,13 +199,16 @@ class SelectChatRoomTypeViewModel( isActionPending.value = true + val nostrPrivateKeyBytes = nostrPrivateKey.value.toByteArray() val keyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() + privKey = nostrPrivateKeyBytes ) when (selectedChatRoomType.value) { ChatRoomType.CONVENIENT -> createMarmotChatRoom(keyPair, onNavigateToRoute) - ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, onNavigateToRoute) + // The ceremony needs the secret itself, not the pair: the ChillDKG host + // key is derived from it rather than being it. + ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, nostrPrivateKeyBytes, onNavigateToRoute) } } @@ -287,13 +301,23 @@ class SelectChatRoomTypeViewModel( * invite anyone to and no key package to wait on — everybody picked is in the room * the moment it exists, and learns about it from the first message. * - * TODO: the quorum the user picked has nowhere to live here. NIP-17 has no group - * state to change and so nothing to approve — the member set is whatever a message - * is addressed to, and a different set is simply a different room. Enforcing t-of-n - * needs a governance layer this protocol does not have. + * That first message is the key ceremony, opened here rather than left for somebody + * to find a button for. + * + * NIP-17 itself has nothing for the quorum to govern — no group state to change, and + * a different member set is simply a different room — so the only thing that can + * carry it is a key the group generates together and can only sign with t of n of + * them present. Everything that ceremony needs is settled by the time the room + * exists: who is in it, and how many of them have to agree. Waiting would mean + * handing the user the room they asked for minus the thing that makes it robust. + * + * Opening it is also how the rest of the group hears of the room at all. Standing up + * a NIP-17 room sends nothing to anybody; the proposal is the first event out, and + * the inbound path builds the same room on the other side from its p-tags. */ private fun createNip17ChatRoom( keyPair: KeyPair, + nostrPrivateKey: ByteArray, onNavigateToRoute: (Route) -> Unit ) { viewModelScope.launch(Dispatchers.IO) { @@ -304,18 +328,43 @@ class SelectChatRoomTypeViewModel( description = description ) - withContext(Dispatchers.Main) { - isActionPending.value = false - - if (localChatRoom == null) { + if (localChatRoom == null) { + withContext(Dispatchers.Main) { + isActionPending.value = false onNavigateToRoute.invoke( ImplementationPendingRoute("Something went wrong") ) + } + return@launch + } + + createdChatRoomId.value = localChatRoom.chatRoom.id + + // The room's membership is the set this screen was opened for, so the + // quorum picked against [adminCount] is the same t-of-n the ceremony is + // asked for. A room that already has a ceremony -- the id is derived from + // its members, so making the same group twice returns the same room -- + // hands that one back instead of opening a second. + val session = dkgRepository.proposeRitual( + localChatRoom = localChatRoom, + userPublicKey = keyPair.pubKey.toHexKey(), + nostrPrivateKey = nostrPrivateKey, + threshold = quorum.value + ) + + withContext(Dispatchers.Main) { + isActionPending.value = false + + if (session == null) { + // Nothing is rolled back: the room works, the group can talk in it, + // and the ceremony can be opened again from the group's details. + // Said here rather than navigated past, because a robust group + // without a shared key is not what the user asked for. + logger.e("Created ${localChatRoom.chatRoom.id} without a key ceremony") + keyCeremonyNotStarted.value = true return@withContext } - createdChatRoomId.value = localChatRoom.chatRoom.id - onNavigateToRoute.invoke( ChatRoomMessagingRoute( activeUserPublicKey = keyPair.pubKey.toHexKey(), @@ -393,7 +442,8 @@ class SelectChatRoomTypeViewModel( initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, activeWalletStateFlow: StateFlow, nostrRepository: NostrRepository, - chatRepository: ChatRepository + chatRepository: ChatRepository, + dkgRepository: DkgRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { SelectChatRoomTypeViewModel( @@ -404,7 +454,8 @@ class SelectChatRoomTypeViewModel( initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, activeWalletStateFlow = activeWalletStateFlow, nostrRepository = nostrRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + dkgRepository = dkgRepository ) } } From 2089fdf8f025ede9e98a92b3f1cbb4921f7abbc4 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:09:06 +0200 Subject: [PATCH 3/3] test: cover the ceremony a robust room is created with 0c240a3 made a claim about a room's first message and de3b355 made one about its second ceremony, and neither is visible from any of the pieces already under test. ChillDkgRitualOrderingTest runs the ChillDKG calls, DkgSession- DaoJvmTest pins the state a ritual resumes from, NostrNip17DaoJvmTest covers the room. What none of them can see is a room and a ceremony together: that the room is empty until the ceremony, and that the ceremony is what fills it. This runs the real thing against a real database. Room's in-memory builder, the host's bundled SQLite, actual secp256k1 -- the room id is derived by doing point work over the member set, and each member's ChillDKG host key is derived from their nostr secret, so the keys are real KeyPairs rather than hex filler. Nothing is stubbed; createNip17ChatRoom and proposeRitual are called exactly as the view model calls them. jvmTest rather than commonTest because it needs a database. jvmTest goes 333 -> 336; commonTest is unchanged at 217. ## What is pinned - A robust room has no chat messages and nothing queued until the ceremony opens, and the first line in it afterwards is TYPE_DKG_STARTED. That is the whole of "the ceremony is the room's first message", stated as the before and the after rather than as a count. - The first event out is the 30310 proposal, authored by the creator. - It carries the whole membership. Receivers derive `n` from the p-tags plus the sender rather than from their own view of the room, so this is the claim that decides whether three devices can agree on one ceremony. - It carries the quorum that was picked, and the session id it opens. - The session runs at that quorum, over three participants, coordinated by the room's creator. - The creator's host key is already out, and hostKeyApprovedAt is set: opening a ceremony is the act of agreeing to be in it, so the member who opened it is not asked again. - Creating the same group twice returns the same room and the same ceremony -- asked for a different quorum the second time, and answered with the running one. One proposal on the wire, one line in the chat. - A FAILED ceremony is replaced rather than handed back, and the group proposes again. ## Checked against a mutation, not just run The re-entry test is the one that could pass for the wrong reason, so the guard it covers was deliberately broken -- `takeIf { false }`, which is de3b355 reverted -- and it failed on its own while the other two passed. Ordering is read off the autoincrement id rather than createdAt. Both chat rows are written inside one proposeRitual call and can land on the same timestamp, which would make an ORDER BY createdAt assertion pass or fail on timing. Co-Authored-By: Claude Opus 5 --- .../managers/RobustRoomKeyCeremonyTest.kt | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt new file mode 100644 index 00000000..b4808780 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt @@ -0,0 +1,224 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatMessage +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * What a group gets for choosing "robust": the key ceremony its room opens with. + * + * The quorum picked while creating the group has nothing in NIP-17 to govern -- + * there is no group state to change, and a different member set is simply a + * different room -- so the only thing that can carry it is a t-of-n key the + * members generate together. Everything that ceremony needs is settled the + * moment the room exists, so `SelectChatRoomTypeViewModel.createNip17ChatRoom` + * opens one there and then rather than leaving it for somebody to find a button + * for. These are the properties that has to hold to. + * + * The proposal being the room's *first* event is not a nicety. Standing up a + * NIP-17 room sends nothing to anybody, so until something goes out the group + * exists on one device only; the proposal is what the other members hear the + * room from at all, which is why `NostrDao` builds the room from a DKG payload's + * p-tags on the way in. + * + * The view model itself is not exercised here -- it needs a wallet's key manager + * and a Compose runtime -- so what stays uncovered is the wiring: that the + * quorum reaching [ChillDkgRitualManager.proposeRitual] is the one the picker + * holds, and that a null session leaves the user on the creation screen. + */ +class RobustRoomKeyCeremonyTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + // Real keys throughout: the room id is derived by doing point work over the + // member set, and the host key each member is identified by for the ceremony + // is derived from their nostr secret. Hex filler exercises neither. + private val creator = KeyPair() + private val user = creator.pubKey.toHexKey() + private val creatorPrivateKey = creator.privKey!! + private val alice = KeyPair().pubKey.toHexKey() + private val bob = KeyPair().pubKey.toHexKey() + + /** The quorum a three-member group is offered by default, and picks here. */ + private val quorum = 2 + + private suspend fun seedProfile(publicKey: String) { + val nostrEventId = publicKey.take(63) + "f" + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert( + Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId) + ) + } + + /** The room the creation screen leaves behind for a robust group. */ + private suspend fun robustRoom(): LocalChatRoom { + listOf(user, alice, bob).forEach { seedProfile(it) } + + return assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = listOf(alice, bob), + subject = "Robust Group", + ), + "createNip17ChatRoom returned null", + ) + } + + private suspend fun openCeremony(room: LocalChatRoom, threshold: Int = quorum) = + ChillDkgRitualManager.proposeRitual( + database = db, + localChatRoom = room, + userPublicKey = user, + nostrPrivateKey = creatorPrivateKey, + threshold = threshold, + ) + + private suspend fun ritualPayloads(room: LocalChatRoom) = + db.giftWrapPayloadDao().getByChatRoomAndKinds(room.chatRoom.id, DkgRitualEvents.ALL.toList()) + + /** The room's lines in the order they were written, id being the insertion order. */ + private suspend fun chatMessages(room: LocalChatRoom) = + db.chatMessageDao().getChatMessagesByChatRoomId(room.chatRoom.id) + .map { it.chatMessage } + .sortedBy { it.id } + + /** + * The whole of the change: a robust room has nothing in it until the ceremony, + * and the ceremony is the first thing in it. + */ + @Test + fun `a robust room's first message is the key ceremony it opens with`() = runBlocking { + val room = robustRoom() + + assertTrue(chatMessages(room).isEmpty(), "the room says nothing until something is sent") + assertTrue(ritualPayloads(room).isEmpty(), "and nothing has gone out yet") + + val session = openCeremony(room) + + assertEquals( + ChatMessage.TYPE_DKG_STARTED, + chatMessages(room).first().messageType, + "the first line in a robust room is its ceremony opening", + ) + + val proposal = ritualPayloads(room).first() + assertEquals(DkgRitualEvents.PROPOSAL, proposal.kind, "and the first event out is the proposal") + assertEquals(user, proposal.publicKey) + + // What every receiver rebuilds the ceremony from: `n` is the p-tag set plus + // the sender, and `t` is the quorum the group was created on. Both are read + // off this event rather than off the receiver's own view of the room, which + // is the thing that drifts. + assertEquals( + setOf(user, alice, bob), + proposal.participantPTags().map { it.pubKey }.toSet(), + "the proposal carries the whole membership", + ) + assertEquals(quorum, DkgRitualEvents.parseThreshold(proposal.tags)) + assertEquals(session.id, DkgRitualEvents.parseSessionId(proposal.tags)) + + assertEquals(quorum, session.threshold, "the ceremony runs at the quorum that was picked") + assertEquals(3, session.participantCount) + assertEquals(user, session.coordinatorPublicKey, "the room's creator coordinates") + + // Opening a ceremony is the act of agreeing to be in it, so this member is + // not asked again -- and their host key goes out with the proposal. + assertNotNull(session.hostKeyApprovedAt) + assertEquals( + 1, + ritualPayloads(room).count { it.kind == DkgRitualEvents.HOST_KEY }, + "the coordinator joins the ceremony it opened", + ) + } + + /** + * Creation is re-enterable -- the room id is derived from the member set, so the + * same group made twice is the same room -- and the ceremony has to be + * re-enterable with it. A second proposal is a second participant set for every + * member to reconcile, and the key the first one produced would be left with + * nothing pointing at it. + */ + @Test + fun `making the same group twice does not open a second ceremony`() = runBlocking { + val room = robustRoom() + val first = openCeremony(room) + + val again = assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = listOf(alice, bob), + subject = "Robust Group", + ) + ) + assertEquals(room.chatRoom.id, again.chatRoom.id, "the same members are the same room") + + // Asked for a different quorum this time: the running ceremony settled that + // question when it opened, and answers with itself rather than the ask. + val second = openCeremony(again, threshold = 3) + + assertEquals(first.id, second.id) + assertEquals(quorum, second.threshold) + assertEquals( + 1, + ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL }, + "one proposal, not two", + ) + assertEquals( + 1, + chatMessages(room).count { it.messageType == ChatMessage.TYPE_DKG_STARTED }, + "and the room is told once", + ) + } + + /** + * A collapsed ceremony leaves the group with no key and a room they can still + * talk in, which is exactly the group that should be able to try again. Failed + * is the one state that does not hold the room's ceremony slot. + */ + @Test + fun `a failed ceremony is replaced rather than handed back`() = runBlocking { + val room = robustRoom() + val abandoned = openCeremony(room) + + db.dkgSessionDao().upsert(abandoned.copy(stage = DkgRitualStage.FAILED)) + + val replacement = openCeremony(room) + + assertNotEquals(abandoned.id, replacement.id) + assertEquals(DkgRitualStage.COLLECTING_HOST_KEYS, replacement.stage) + assertEquals( + 2, + ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL }, + "the group proposes again", + ) + } +}