feat: tell the group in chat when a shared key ceremony happens

A ChillDKG ritual was invisible to everyone it happened to. Kinds 30310-30316
are routed to ChillDkgRitualManager and never become ChatMessage rows, so a
member's device published a host key and joined a ceremony that fixes the
group's signing quorum for good, with nothing appearing anywhere they would
look. The only way to find out was to open the group's details and press Shared
Key on the off chance. Worse in the flow this is reached through: a group whose
first event is the ceremony now materialises as a room with no messages in it at
all and no explanation of why it appeared.

That matters more than it would for a chat feature, because a ritual cannot
finish until every member's device has taken part. The progress ladder on the
ritual screen exists to show that it is waiting on 2 of 3 -- but nothing told
member 3 they were the one being waited on.

Three milestones now land in the transcript: the ceremony starting, the group
getting a key, and the ceremony being abandoned (with the reason, which names
the culprit participant when ChillDKG identified one).

## Derived locally, not sent

Nothing new goes on the wire. Every member already receives the proposal, and
computes the completion and any failure for themselves, so each device writes
its own row from what it already has. That costs no traffic, needs no new event
kind, and -- the reason it is worth doing this way -- makes it impossible for the
transcript to disagree with the ritual it describes. A "ceremony started" message
that was itself sent could arrive without the proposal, or outlive a session that
never existed on that device.

The rows are written where the state changes: announceStarted() at both places a
DkgSession is created (proposeRitual for the member who opens it, acceptProposal
for everyone else), and announce() at the COMPLETE write and in fail().

They are written once by construction rather than by de-duplication, which is
worth spelling out because ChatMessage.id is autogenerated and a second insert
would simply be a second line. A session is created once, since acceptProposal
returns early when the row exists; advance() leaves a COMPLETE ritual alone; and
fail() now re-reads the session and returns if it is already FAILED. That last
one is also a fix in its own right -- a ritual can be failed from two directions,
a FAILURE message from a member and a fault raised locally, and while the second
write was previously harmless it would now have told the group twice.

## Rendered as a system line, not a bubble

ChatMessage.messageType already carries "message", "artifact", "pendingCommit"
and eleven others, so TYPE_DKG_STARTED / _COMPLETE / _FAILED join it with no
schema change. But the list renders every row as a bubble with the sender's name
and a delivery-status icon, and neither fits: "a shared key ceremony started" is
not something the coordinator said, and a row with no gift wrap behind it would
show the KeyOff "unsealed" icon as though it had failed to send.

RitualNotice renders them across the width instead -- icon, text, timestamp, no
author, no side, no delivery state -- and is tappable through to the ritual
screen, since the point of telling the group is to give them somewhere to go. It
branches out of the items() lambda with an early return so the existing bubble
layout is untouched.

The other informational types ("pendingCommit", "processedCommit",
"proposalStaged", "undecryptableOuterLayer") have the same problem and are
deliberately left alone: how MLS commit rows should read is a separate call from
making the key ceremony visible.

## Not covered by tests

The whole change is Room writes and Compose rendering, neither of which runs
under :composeApp:testDebugUnitTest -- there is no sqlite driver on the JVM test
classpath. Verified by compilation only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 00:35:52 +02:00
parent 61869f0046
commit 61480dd8b7
4 changed files with 180 additions and 4 deletions

View File

@@ -85,6 +85,24 @@ data class ChatMessage(
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
companion object {
/**
* A ChillDKG ritual reached a point the group should be told about.
*
* These rows are not anybody's words: no [ChatMessage.giftWrapPayloadId], no
* event behind them, and nothing sent to say them. Each device writes its own
* from the ritual messages it already received, which is why they cost no
* traffic and cannot disagree with the ritual they describe.
*
* [ChatMessageListViewModel] renders these as a system line rather than a
* bubble, since attributing "a shared key ceremony started" to the
* coordinator would read as something they said.
*/
const val TYPE_DKG_STARTED = "dkgStarted"
const val TYPE_DKG_COMPLETE = "dkgComplete"
const val TYPE_DKG_FAILED = "dkgFailed"
val DKG_TYPES = setOf(TYPE_DKG_STARTED, TYPE_DKG_COMPLETE, TYPE_DKG_FAILED)
suspend fun fromGroupEventResult(
database: MantraDatabase,
activeKeyPair: KeyPair,

View File

@@ -1,6 +1,7 @@
package press.mantra.compose.managers
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.GiftWrapPayload
@@ -150,6 +151,7 @@ object ChillDkgRitualManager {
round2AuxRandom = RandomInstance.bytes(32).toHex()
)
database.dkgSessionDao().upsert(session)
announceStarted(database, session)
logger.i("Proposing DKG ritual $sessionId: $threshold-of-${members.size}")
@@ -285,6 +287,7 @@ object ChillDkgRitualManager {
round2AuxRandom = RandomInstance.bytes(32).toHex()
)
database.dkgSessionDao().upsert(session)
announceStarted(database, session)
logger.i("Joined DKG ritual $sessionId ($threshold-of-${members.size})")
@@ -504,6 +507,13 @@ object ChillDkgRitualManager {
recoveryData = output.recovery?.toHex()
)
}
announce(
database = database,
session = session,
messageType = ChatMessage.TYPE_DKG_COMPLETE,
content = "The group has a shared key. It takes ${session.threshold} of " +
"${session.participantCount} members to sign with it."
)
logger.i("DKG ritual $sessionId complete")
} catch (e: CancellationException) {
@@ -696,9 +706,66 @@ object ChillDkgRitualManager {
}
private suspend fun fail(database: MantraDatabase, session: DkgSession, reason: String) {
update(database, session) {
// Read before writing so the notice goes out once. A ritual can be failed
// from two directions -- a FAILURE message from a member, and a fault raised
// locally -- and the group does not need to be told twice.
val current = database.dkgSessionDao().getSessionById(session.id) ?: session
if (current.stage == DkgRitualStage.FAILED) return
update(database, current) {
it.copy(stage = DkgRitualStage.FAILED, failureReason = reason)
}
announce(
database = database,
session = current,
messageType = ChatMessage.TYPE_DKG_FAILED,
content = "The shared key ceremony was abandoned — $reason. " +
"No key was created, and it is safe to run it again."
)
}
private suspend fun announceStarted(database: MantraDatabase, session: DkgSession) = announce(
database = database,
session = session,
messageType = ChatMessage.TYPE_DKG_STARTED,
content = "A shared key ceremony started. It will take ${session.threshold} of " +
"${session.participantCount} members to sign with the key, and it finishes once " +
"everyone has taken part."
)
/**
* Puts a ritual milestone in the group's chat.
*
* The ritual is otherwise invisible: its kinds never become chat messages, so a
* member's device would join a ceremony that fixes the group's signing quorum
* for good without anything appearing where they would look. Each device writes
* its own row from the ritual messages it has already received, so this costs no
* traffic and cannot disagree with the ritual it describes.
*
* Written once per milestone by construction, not by de-duplication: a session
* is created once (its caller returns early if the row exists), [advance] leaves
* a completed ritual alone, and [fail] checks the stage before it writes. There
* is no key on ChatMessage to make a second insert idempotent -- its id is
* autogenerated -- so those guards are what keep the transcript honest.
*/
private suspend fun announce(
database: MantraDatabase,
session: DkgSession,
messageType: String,
content: String
) {
database.chatMessageDao().upsert(
ChatMessage(
senderPublicKey = session.coordinatorPublicKey,
isUserMessage = session.isCoordinator(),
giftWrapPayloadId = null,
marmotGroupEventId = null,
marmotInnerEventId = null,
chatRoomId = session.chatRoomId,
content = content,
messageType = messageType
)
)
}
private suspend fun publishHostKey(

View File

@@ -42,6 +42,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
@@ -132,7 +133,16 @@ fun ChatRoomMessagingScreen(
modifier = Modifier.weight(1f).fillMaxWidth()
) {
key(true) {
chatMessageListViewModel.RenderMessages()
chatMessageListViewModel.RenderMessages(
onOpenSharedKey = {
onNavigateToRoute.invoke(
DkgRitualRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
)
}
)
}
}

View File

@@ -1,5 +1,6 @@
package press.mantra.compose.ui.view.model
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -18,7 +19,11 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.Pending
import androidx.compose.material3.Card
@@ -43,6 +48,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.SynchronizationFilter
@@ -198,7 +204,7 @@ class ChatMessageListViewModel(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RenderMessages() {
fun RenderMessages(onOpenSharedKey: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
@@ -295,6 +301,17 @@ class ChatMessageListViewModel(
items = chatRoomDetailMessageListUIState.chatMessageList,
key = { it.chatMessage.id }
) { localChatMessage ->
// Not somebody's words -- see ChatMessage.DKG_TYPES.
// A bubble would attribute "a shared key ceremony
// started" to the coordinator as if they had said it.
if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) {
RitualNotice(
chatMessage = localChatMessage.chatMessage,
onClick = onOpenSharedKey
)
return@items
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
@@ -446,4 +463,68 @@ class ChatMessageListViewModel(
}
}
}
}
}
/**
* A ChillDKG milestone, as a system line across the transcript.
*
* Deliberately not a bubble: nobody said this, and giving it a sender and a side
* would make the coordinator appear to have announced it. It is tappable because
* the point of telling the group is to give them somewhere to go — a ritual only
* finishes once every member's device has taken part, and the ladder that shows
* who it is waiting on lives on the shared-key screen.
*/
@Composable
private fun RitualNotice(
chatMessage: ChatMessage,
onClick: () -> Unit,
) {
val icon = when (chatMessage.messageType) {
ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline
else -> Icons.Default.Key
}
val tint = when (chatMessage.messageType) {
ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tint
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = chatMessage.content,
style = MaterialTheme.typography.bodySmall,
color = tint
)
Text(
text = chatMessage.createdAt.toFormattedTimeAndDateString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Open the shared key ceremony",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}