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