fix: name the member behind a shared key ceremony notice

The ritual notices landed without an author. Dropping the bubble was right --
"a shared key ceremony started" is not something the coordinator said -- but
dropping the actor with it threw away the part of the line that matters most.
Any member can open a ceremony, and it settles the group's signing quorum for
good, so *who* opened this one is exactly what the group needs to see. Same for
who abandoned one.

System lines carry the actor in the sentence rather than in a header, so the
content of the authored types is now a predicate to be read after a name:

  Alice  started a shared key ceremony. It will take 2 of 3 members to sign
         with the key, and it finishes once everyone has taken part.
  Bob    abandoned the shared key ceremony. No key was created, and it is safe
         to run it again.
  ✓      The group has a shared key. It takes 2 of 3 members to sign with it.

A finished ceremony keeps no author, which is why DKG_AUTHORED_TYPES is a subset
rather than all three: the group ends up with a key, nobody hands it to them.

## The actor is resolved at render time, not written into the content

The manager could look the name up when it writes the row, and it would be wrong
twice over: the name would be frozen against later renames, and a member first
seen through this very proposal is sitting on the "LOADING..." placeholder that
getOrCreateNip17ChatRoom just inserted for them -- so the line would read
"LOADING... started a shared key ceremony" forever. LocalChatMessage already
joins Profile on senderPublicKey, so the renderer resolves it live, colours it
with ProfileColor like the message bubbles do, and falls back to a short key
when there is no profile yet.

## senderPublicKey now holds who acted

It was the coordinator on all three notices, which was wrong for an abandoned
ceremony: the member who sent FAILURE is the one who ended it. fail() takes a
culprit -- the FAILURE sender, defaulting to this device for a fault raised
locally, which amounts to the same thing from the group's side since hitting one
makes this device broadcast FAILURE in turn. isUserMessage follows from it, so
the line reads "You" for your own actions.

The fault itself is deliberately no longer in the chat line. "Abandoned by
1a2b3c4d: ChillDKG round 2 failed: a participant is faulty (participant 2)" in
the middle of a sentence about who walked away reads badly, and the detail is
already on the ritual screen the notice taps through to. DkgSession.failureReason
keeps it verbatim, so that screen is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 00:44:53 +02:00
parent 61480dd8b7
commit 95db7c2f73
3 changed files with 78 additions and 15 deletions

View File

@@ -103,6 +103,17 @@ data class ChatMessage(
val DKG_TYPES = setOf(TYPE_DKG_STARTED, TYPE_DKG_COMPLETE, TYPE_DKG_FAILED)
/**
* The ritual milestones somebody did, as opposed to ones that simply
* happened. For these [senderPublicKey] is the member who acted -- the one
* who opened the ceremony, or the one who abandoned it -- and their content
* is written as a predicate for their name to be read in front of.
*
* A finished ceremony has no actor: the group ends up with a key, nobody
* hands it to them.
*/
val DKG_AUTHORED_TYPES = setOf(TYPE_DKG_STARTED, TYPE_DKG_FAILED)
suspend fun fromGroupEventResult(
database: MantraDatabase,
activeKeyPair: KeyPair,

View File

@@ -383,9 +383,10 @@ object ChillDkgRitualManager {
DkgRitualEvents.FAILURE -> {
fail(
database,
session,
"Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}"
database = database,
session = session,
reason = "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}",
culprit = giftWrapPayload.publicKey
)
return false
}
@@ -512,7 +513,8 @@ object ChillDkgRitualManager {
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."
"${session.participantCount} members to sign with it.",
actor = session.coordinatorPublicKey
)
logger.i("DKG ritual $sessionId complete")
@@ -705,7 +707,15 @@ object ChillDkgRitualManager {
if (settled || current.stage.ordinal >= stage.ordinal) current else current.copy(stage = stage)
}
private suspend fun fail(database: MantraDatabase, session: DkgSession, reason: String) {
private suspend fun fail(
database: MantraDatabase,
session: DkgSession,
reason: String,
// Whoever ended it. A member who sent FAILURE, or this device when it hit a
// fault of its own -- which amounts to the same thing from the group's side,
// since hitting one makes this device broadcast FAILURE in turn.
culprit: HexKey = session.userPublicKey
) {
// 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.
@@ -719,8 +729,13 @@ object ChillDkgRitualManager {
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."
// The fault itself is deliberately left out and kept on the ritual
// screen this line taps through to. "a participant is faulty
// (participant 2)" is worth having, but not in the middle of a
// sentence about who walked away.
content = "abandoned the shared key ceremony. No key was created, " +
"and it is safe to run it again.",
actor = culprit
)
}
@@ -728,9 +743,13 @@ object ChillDkgRitualManager {
database = database,
session = session,
messageType = ChatMessage.TYPE_DKG_STARTED,
content = "A shared key ceremony started. It will take ${session.threshold} of " +
// Reads after the actor's name -- see ChatMessage.DKG_AUTHORED_TYPES. Who
// opened a ceremony matters: any member can, and it settles the group's
// signing quorum for good.
content = "started a shared key ceremony. It will take ${session.threshold} of " +
"${session.participantCount} members to sign with the key, and it finishes once " +
"everyone has taken part."
"everyone has taken part.",
actor = session.coordinatorPublicKey
)
/**
@@ -752,12 +771,13 @@ object ChillDkgRitualManager {
database: MantraDatabase,
session: DkgSession,
messageType: String,
content: String
content: String,
actor: HexKey
) {
database.chatMessageDao().upsert(
ChatMessage(
senderPublicKey = session.coordinatorPublicKey,
isUserMessage = session.isCoordinator(),
senderPublicKey = actor,
isUserMessage = actor == session.userPublicKey,
giftWrapPayloadId = null,
marmotGroupEventId = null,
marmotInnerEventId = null,

View File

@@ -40,8 +40,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@@ -50,6 +53,7 @@ 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.LocalChatMessage
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.SynchronizationFilter
import press.mantra.compose.extensions.toFormattedTimeAndDateString
@@ -306,7 +310,7 @@ class ChatMessageListViewModel(
// started" to the coordinator as if they had said it.
if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) {
RitualNotice(
chatMessage = localChatMessage.chatMessage,
localChatMessage = localChatMessage,
onClick = onOpenSharedKey
)
return@items
@@ -476,9 +480,11 @@ class ChatMessageListViewModel(
*/
@Composable
private fun RitualNotice(
chatMessage: ChatMessage,
localChatMessage: LocalChatMessage,
onClick: () -> Unit,
) {
val chatMessage = localChatMessage.chatMessage
val icon = when (chatMessage.messageType) {
ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline
@@ -509,7 +515,30 @@ private fun RitualNotice(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = chatMessage.content,
text = buildAnnotatedString {
// Somebody opened this ceremony, or somebody walked away from it,
// and which member that was is the point of the line. Resolved
// from the joined profile rather than written into the content,
// so it follows a rename and is not stuck on the "LOADING..."
// placeholder a member is given the moment they are first seen.
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES) {
withStyle(
SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey))
) {
append(
if (chatMessage.isUserMessage) {
"You"
} else {
localChatMessage.profile?.humanReadableNameOrPubkey()
?: chatMessage.senderPublicKey.take(SHORTENED_PUBLIC_KEY_LENGTH)
}
)
}
append(" ")
}
append(chatMessage.content)
},
style = MaterialTheme.typography.bodySmall,
color = tint
)
@@ -528,3 +557,6 @@ private fun RitualNotice(
)
}
}
/** Enough of a public key to tell two members apart when neither has a profile yet. */
private const val SHORTENED_PUBLIC_KEY_LENGTH = 8