From 3576c00ce27f25b8f695081deaeba7286d1c8234 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 5 Sep 2026 10:43:01 +0200 Subject: [PATCH] feat: put every ritual message in the group's chat, naming who sent it 61480dd gave the group three lines: a ceremony started, it finished, somebody abandoned it. Between the first and the second the ritual was a black box. A ceremony that fixes the group's signing quorum for good ran with nothing to watch, and a stalled one -- the common case, since it finishes only once every member's device has taken part -- gave no way to see which member it was waiting on. Every protocol message now gets a line naming the member whose device sent it: 30311 host key ...joined the shared key ceremony, publishing the key their device is identified by for the rest of it. 30312 round 1 ...sent their contribution to the key. Every member's is mixed in, so no single device ever holds the whole thing. 30313 coord r1 ...combined everyone's contributions and sent the result back to be checked. 30314 round 2 ...checked the combined result and signed to confirm it matches what they sent. 30315 certificate ...gathered everyone's confirmations into a certificate. A device that has it can finish and keep its share of the key. The wording says what the step accomplishes rather than what it is called. "sent pmsg1" is not a useful thing to read in a group chat, and the protocol names are already on the shared-key screen this line taps through to. 30310 and 30316 keep the announcers they have: both say more than the message carries -- the quorum in one case, who walked away in the other -- so folding them in here would have lost that. `complete` stays the one unauthored line, because a finished ceremony has no actor: the group ends up with a key, nobody hands it to them. This changes nothing about the protocol and adds no traffic. It extends the mechanism 61480dd established rather than adding one: the rows are written locally from ritual messages this device already has, with no giftWrapPayloadId and no event behind them, so they cannot disagree with the ritual they describe and no new kind goes on the wire. Adding the five types to DKG_TYPES and DKG_AUTHORED_TYPES was enough to get RitualNotice's system-line treatment and its name prefix, which resolves from the joined profile and so follows a rename. The one genuinely new problem is idempotency, and it is the reason for six hooks rather than one. ChatMessage has no key to make a second insert a no-op -- its id is autogenerated -- while ritual messages arrive repeatedly: relays redeliver, and replayStoredMessages feeds the whole backlog through record() again on every resume. DkgParticipantMessage absorbs both, being keyed on (sessionId, participantPublicKey, kind); a chat row cannot. So each announce is guarded by reading the row it is about to write over, the same read-before-write fail() already documents: - record(), for the three per-member kinds, checks getMessage before the upsert - record(), for the two aggregates, reads the column before update() - publishOwn(), for this device's own messages, checks ownMessage - the two coordinator broadcast sites, already inside `== null` guards publishOwn's check is deliberately redundant with its callers', which all check before publishing. The chat row is the thing with no fallback, so the check that matters sits next to the write. An echo of this device's own broadcast finds the row already stored and stays silent. Worth knowing before this meets a real group: it is 3n + 4 lines per ritual -- 19 for five members, 34 for ten -- and a ritual is a burst, not a trickle. They are compact single lines, but they will dominate a transcript while a ceremony runs. If that reads as noise, the cheap fix is collapsing consecutive ritual lines into one expandable line in ChatMessageListViewModel; the types are distinct enough to group on, so no data change would be needed. That judgement wants a real ritual first, which is also the only thing that will exercise these paths -- they are the same paths the protocol messages take, and none of it has run on a device. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 48 +++++-- .../compose/managers/ChillDkgRitualManager.kt | 127 ++++++++++++++++-- 2 files changed, 154 insertions(+), 21 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 814e41ae..a5ef8f7e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -86,7 +86,11 @@ data class ChatMessage( companion object { /** - * A ChillDKG ritual reached a point the group should be told about. + * A ChillDKG ritual message, as a line in the group's chat. + * + * There is one per protocol message the ritual sends -- every member's host + * key, both of their rounds, and both of the coordinator's aggregates -- + * plus the three that bracket them: started, complete, abandoned. * * 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 @@ -98,21 +102,47 @@ data class ChatMessage( * coordinator would read as something they said. */ const val TYPE_DKG_STARTED = "dkgStarted" + const val TYPE_DKG_HOST_KEY = "dkgHostKey" + const val TYPE_DKG_ROUND_1 = "dkgRound1" + const val TYPE_DKG_COORDINATOR_ROUND_1 = "dkgCoordinatorRound1" + const val TYPE_DKG_ROUND_2 = "dkgRound2" + const val TYPE_DKG_CERTIFICATE = "dkgCertificate" 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) + val DKG_TYPES = setOf( + TYPE_DKG_STARTED, + TYPE_DKG_HOST_KEY, + TYPE_DKG_ROUND_1, + TYPE_DKG_COORDINATOR_ROUND_1, + TYPE_DKG_ROUND_2, + TYPE_DKG_CERTIFICATE, + 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. + * The ritual lines somebody did, as opposed to ones that simply happened. + * For these [senderPublicKey] is the member who acted, 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. + * Every protocol message is one of these: each is a thing a named member's + * device sent, and which member it was is the whole point of showing it -- + * a ritual finishes only once every member has taken part, so a stalled one + * is a question about who has not. + * + * A finished ceremony is the exception, and 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) + val DKG_AUTHORED_TYPES = setOf( + TYPE_DKG_STARTED, + TYPE_DKG_HOST_KEY, + TYPE_DKG_ROUND_1, + TYPE_DKG_COORDINATOR_ROUND_1, + TYPE_DKG_ROUND_2, + TYPE_DKG_CERTIFICATE, + TYPE_DKG_FAILED, + ) suspend fun fromGroupEventResult( database: MantraDatabase, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index 9364720a..70f9353d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -345,14 +345,27 @@ object ChillDkgRitualManager { DkgRitualEvents.HOST_KEY, DkgRitualEvents.ROUND_1, - DkgRitualEvents.ROUND_2 -> database.dkgSessionDao().upsert( - DkgParticipantMessage( - sessionId = session.id, - participantPublicKey = giftWrapPayload.publicKey, - kind = giftWrapPayload.kind, - payload = giftWrapPayload.content + DkgRitualEvents.ROUND_2 -> { + // Read before writing so the line goes out once. Relays redeliver, + // and a replay feeds the whole backlog through here again; the + // upsert below absorbs that, but a chat row has no key to absorb it + // with. + val known = database.dkgSessionDao() + .getMessage(session.id, giftWrapPayload.kind, giftWrapPayload.publicKey) != null + + database.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = session.id, + participantPublicKey = giftWrapPayload.publicKey, + kind = giftWrapPayload.kind, + payload = giftWrapPayload.content + ) ) - ) + + if (!known) { + announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + } + } // Both aggregates come from the coordinator and only ever once. Taking // them from anyone else lets any member stall the ritual by getting a @@ -360,6 +373,8 @@ object ChillDkgRitualManager { DkgRitualEvents.COORDINATOR_ROUND_1 -> { if (!isFromCoordinator(session, giftWrapPayload)) return true + val known = current(database, session).coordinatorRound1 != null + update(database, session) { current -> if (current.coordinatorRound1 == null) { current.copy(coordinatorRound1 = giftWrapPayload.content) @@ -367,11 +382,17 @@ object ChillDkgRitualManager { current } } + + if (!known) { + announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + } } DkgRitualEvents.CERTIFICATE -> { if (!isFromCoordinator(session, giftWrapPayload)) return true + val known = current(database, session).certificate != null + update(database, session) { current -> if (current.certificate == null) { current.copy(certificate = giftWrapPayload.content) @@ -379,6 +400,10 @@ object ChillDkgRitualManager { current } } + + if (!known) { + announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + } } DkgRitualEvents.FAILURE -> { @@ -447,6 +472,12 @@ object ChillDkgRitualManager { session = update(database, session) { it.copy(coordinatorRound1 = cmsg1) } broadcast(database, localChatRoom, session, DkgRitualEvents.COORDINATOR_ROUND_1, cmsg1) + announceStep( + database, + session, + DkgRitualEvents.COORDINATOR_ROUND_1, + session.userPublicKey + ) } val coordinatorRound1 = session.coordinatorRound1 ?: return @@ -487,6 +518,7 @@ object ChillDkgRitualManager { session = update(database, session) { it.copy(certificate = cmsg2) } broadcast(database, localChatRoom, session, DkgRitualEvents.CERTIFICATE, cmsg2) + announceStep(database, session, DkgRitualEvents.CERTIFICATE, session.userPublicKey) } val certificate = session.certificate ?: return @@ -752,6 +784,69 @@ object ChillDkgRitualManager { actor = session.coordinatorPublicKey ) + /** + * Puts one protocol message in the group's chat, as a line naming who sent it. + * + * The ritual is otherwise a black box. Its kinds are gift-wrapped payloads that + * never become chat messages, so a ceremony that fixes the group's signing + * quorum for good ran with nothing to watch, and a stalled one gave no way to + * see which member it was waiting on. Naming the sender is the point: every one + * of these is a thing a particular member's device did. + * + * The wording describes what the step accomplishes rather than what it is + * called. "sent their contribution to the key" is a true and useful thing to + * read in a group chat; "sent pmsg1" is not, and the protocol names are on the + * shared-key screen for anyone who wants them. + * + * Each is written once per device, guarded at the point of first arrival -- + * relays redeliver and [replayStoredMessages] feeds the whole backlog through + * again, both of which the keyed [DkgParticipantMessage] absorbs and a chat row + * cannot. + */ + private suspend fun announceStep( + database: MantraDatabase, + session: DkgSession, + kind: Kind, + actor: HexKey + ) { + val (messageType, content) = when (kind) { + DkgRitualEvents.HOST_KEY -> ChatMessage.TYPE_DKG_HOST_KEY to + "joined the shared key ceremony, publishing the key their device is " + + "identified by for the rest of it." + + DkgRitualEvents.ROUND_1 -> ChatMessage.TYPE_DKG_ROUND_1 to + "sent their contribution to the key. Every member's is mixed in, so no " + + "single device ever holds the whole thing." + + DkgRitualEvents.COORDINATOR_ROUND_1 -> ChatMessage.TYPE_DKG_COORDINATOR_ROUND_1 to + "combined everyone's contributions and sent the result back to be checked." + + DkgRitualEvents.ROUND_2 -> ChatMessage.TYPE_DKG_ROUND_2 to + "checked the combined result and signed to confirm it matches what they sent." + + DkgRitualEvents.CERTIFICATE -> ChatMessage.TYPE_DKG_CERTIFICATE to + "gathered everyone's confirmations into a certificate. A device that has " + + "it can finish and keep its share of the key." + + // PROPOSAL and FAILURE are announced by the code that acts on them -- + // both say more than the message itself carries, the quorum in one case + // and who walked away in the other. + else -> return + } + + announce( + database = database, + session = session, + messageType = messageType, + content = content, + actor = actor + ) + } + + /** The stored session, for reading a column back before overwriting it. */ + private suspend fun current(database: MantraDatabase, session: DkgSession): DkgSession = + database.dkgSessionDao().getSessionById(session.id) ?: session + /** * Puts a ritual milestone in the group's chat. * @@ -761,11 +856,12 @@ object ChillDkgRitualManager { * 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. + * Written once by construction, not by de-duplication. There is no key on + * ChatMessage to make a second insert idempotent -- its id is autogenerated -- + * so every caller checks before it writes: a session is created once (its caller + * returns early if the row exists), [advance] leaves a completed ritual alone, + * [fail] checks the stage, and [announceStep] reads the row it is about to + * write over. Those guards are what keep the transcript honest. */ private suspend fun announce( database: MantraDatabase, @@ -813,6 +909,11 @@ object ChillDkgRitualManager { ) { broadcast(database, localChatRoom, session, kind, content) + // Guarded here rather than trusting the callers: every one of them already + // checks before publishing, but the chat row is the thing with no key to + // fall back on, so the check that matters lives next to the write. + val known = ownMessage(database, session, kind) != null + database.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = session.id, @@ -821,6 +922,8 @@ object ChillDkgRitualManager { payload = content ) ) + + if (!known) announceStep(database, session, kind, session.userPublicKey) } /**