Merge branch 'mantra' into claude/frost-batch-signing-435bbb

This commit is contained in:
Kgothatso Ngako
2026-09-06 10:03:13 +02:00
5 changed files with 339 additions and 17 deletions

View File

@@ -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.

View File

@@ -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 = {}
)
}

View File

@@ -543,6 +543,7 @@ fun MantraNavHost(
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository,
onNavigateToRoute = { chatRoomResultRoute ->
navController.navigate(
chatRoomResultRoute

View File

@@ -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
)
}
}

View File

@@ -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",
)
}
}