Merge branch 'mantra' into claude/frost-batch-signing-435bbb
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<ActiveWallet?>,
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -543,6 +543,7 @@ fun MantraNavHost(
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
nostrRepository = databaseNostrRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onNavigateToRoute = { chatRoomResultRoute ->
|
||||
navController.navigate(
|
||||
chatRoomResultRoute
|
||||
|
||||
@@ -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<ActiveWallet?>,
|
||||
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<HexKey>()
|
||||
|
||||
/**
|
||||
* 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<Boolean> = 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<ActiveWallet?>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MantraDatabase>()
|
||||
)
|
||||
|
||||
@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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user