From ea3321720364a282096a7e14d374f65209d2220d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 29 Aug 2026 18:39:43 +0200 Subject: [PATCH] feat: build robust groups as NIP-17 instead of Marmot/MLS Robust rooms are now plain NIP-17 group chats: no MLS group, no key packages, no invites, no admin. Convenient rooms are unchanged and stay Marmot/MLS. Everything needed to *run* a NIP-17 group was already here; what was missing was any way to start one. * Inbound already worked for N participants, not just a pair. GiftWrapSeal derives the room id from the author plus every p-tag via ChatRoom.deriveChatRoomId (a musig2 aggregate over the member set), and NostrDao stands the room up with mlsGroupState = null and a Participant row per p-tag, inserting placeholder profiles and queueing a profile sync for anyone unknown. * Outbound already worked too. sendChatMessage branches on mlsGroupState == null into gift wraps p-tagged to every participant, sealGiftWrapPayload wraps the payload once per participant, and NotaryViewModel drives that loop at runtime -- so the path is live, not merely present. * The gap was creation. NostrNip17Dao.getOrCreateChatRoom only ever inserts the active user as a participant (the peer is literally commented out of its hexKeys set) and expects the room id to be handed to it, which suits an inbound message and nothing else. database/dao/NostrNip17Dao.kt * New createNip17ChatRoom(): derives the id with the SAME deriveChatRoomId the inbound path uses, so the creator and every recipient independently arrive at the same room, and building the same group twice is idempotent rather than duplicative. Then upserts the room with mlsGroupState = null -- which is precisely the flag sendChatMessage reads to choose gift wraps -- and a Participant row for the creator plus every picked member. repository/ChatRepository.kt, database/repository/DatabaseChatRepository.kt * Expose it, with the same try/catch-and-log-null shape its getOrCreateChatRoom sibling uses, plus the NO_OP stub for previews. ui/view/model/SelectChatRoomTypeViewModel.kt * createChatRoom() splits on the chosen type into createMarmotChatRoom() and createNip17ChatRoom(). The convenient path is the previous body verbatim. The robust path skips key package resolution, the 20-second relay budget and the whole sequential invite loop, because NIP-17 membership IS the p-tag set -- there is nothing to invite anybody to, and so no partial-failure case either. * Removes the .copy(adminPubkeys = ...) added in bf94ecb. Robust was the only thing that ever set a multi-admin list; with convenient now the only MLS path, MarmotGroupData.bootstrap() already stamps creator-only, so the override had become a branch that could not be taken. Trade-offs this bakes in, recorded here and in a TODO on the new path: * The quorum has LESS meaning under NIP-17, not more. There is no group state to change and so nothing to approve: membership is whatever a message is addressed to, and a different member set is a different musig aggregate, i.e. simply a different room. Under MLS there was at least an admin_pubkeys list to hang FROST off later; here there is no object for t-of-n to govern at all. The picker is still shown and still has nowhere to persist to. * Members do not learn the room exists until the first message is sent. NIP-17 has no invite event -- the first gift wrap is the invitation. * Robust rooms give up MLS forward secrecy and the sender ratchet. What they gain is that there is no privileged member and no shared group state to desync. Verified: ./gradlew :composeApp:compileCommonMainKotlinMetadata ./gradlew :composeApp:compileDebugKotlinAndroid --rerun-tasks Co-Authored-By: Claude Opus 5 --- .../compose/database/dao/NostrNip17Dao.kt | 52 +++++++++++++ .../repository/DatabaseChatRepository.kt | 17 ++++ .../compose/repository/ChatRepository.kt | 20 +++++ .../view/model/SelectChatRoomTypeViewModel.kt | 77 +++++++++++++++---- 4 files changed, 149 insertions(+), 17 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt index f285d757..05f71985 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt @@ -29,6 +29,58 @@ abstract class NostrNip17Dao( ) { val logger = Logger.withTag(TAG) + /** + * Stands up a NIP-17 group locally: no MLS group, no key packages, no invites. + * Membership under NIP-17 *is* the p-tag set on each message, so all that has to + * exist up front is the room and its participants — the first message carries the + * membership to everyone else. + * + * The id is [ChatRoom.deriveChatRoomId] over the members, the same aggregate the + * inbound path derives from an arriving gift wrap. That makes it idempotent: two + * people building the same group land on the same room rather than two. + */ + @Transaction + open suspend fun createNip17ChatRoom( + userPublicKey: HexKey, + participantPublicKeys: List, + subject: String? = null, + description: String? = null, + relayHint: String? = Relays.DefaultDMRelayList.first().url + ): LocalChatRoom? { + val memberPublicKeys = (participantPublicKeys + userPublicKey).toSet() + val chatRoomId = ChatRoom.deriveChatRoomId(memberPublicKeys) + + logger.d("createNip17ChatRoom: $chatRoomId for ${memberPublicKeys.size} member(s)") + + database.chatRoomDao().findChatRoomById(chatRoomId)?.let { existingChatRoom -> + logger.i("Chat room $chatRoomId already exists; reusing it") + return existingChatRoom + } + + val chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = userPublicKey, + subject = subject, + description = description, + // No MLS state is what marks this a NIP-17 room: `sendChatMessage` reads + // exactly this to decide between a group event and gift wraps. + mlsGroupState = null + ) + database.chatRoomDao().upsert(chatRoom) + + database.participantDao().upsert( + memberPublicKeys.map { participantPublicKey -> + Participant( + participantPublicKey = participantPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + } + ) + + return database.chatRoomDao().findChatRoomById(chatRoomId) + } + @Transaction open suspend fun getOrCreateChatRoom( chatRoomId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index fa1955df..1f0e7364 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -118,6 +118,23 @@ class DatabaseChatRepository( return database.marmotKeyPackageDao().observeMarmotKeyPackageForPublicKey(publicKey) } + override suspend fun createNip17ChatRoom( + userPublicKey: HexKey, + participantPublicKeys: List, + subject: String?, + description: String? + ): LocalChatRoom? = try { + database.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = userPublicKey, + participantPublicKeys = participantPublicKeys, + subject = subject, + description = description + ) + } catch (e: Throwable) { + logger.e("Error creating nip-17 chat room", e) + null + } + override suspend fun createMlsDirectMessage( name: String?, description: String?, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 3f712124..2d74f0d2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -42,6 +42,17 @@ interface ChatRepository { suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow + /** + * Creates a NIP-17 group: participants and a room, no MLS group. Membership is + * the p-tag set carried by each message, so there is nothing to invite anyone to. + */ + suspend fun createNip17ChatRoom( + userPublicKey: HexKey, + participantPublicKeys: List, + subject: String? = null, + description: String? = null + ): LocalChatRoom? + suspend fun createMlsDirectMessage( name: String? = null, description: String? = null, @@ -133,6 +144,15 @@ interface ChatRepository { TODO("Not yet implemented") } + override suspend fun createNip17ChatRoom( + userPublicKey: HexKey, + participantPublicKeys: List, + subject: String?, + description: String? + ): LocalChatRoom? { + return null + } + override suspend fun createMlsDirectMessage( name: String?, description: String?, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt index 24498cd0..7713c3df 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -192,22 +192,21 @@ class SelectChatRoomTypeViewModel( privKey = nostrPrivateKey.value.toByteArray() ) - val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost... - - // Convenient rooms keep the creator as the lone admin; robust rooms hand - // everyone the same authority. This is the whole behavioural difference - // between the two — the inbound path derives Participant.adminAt from - // exactly this list. - // TODO: Robust rooms still need the t-of-n approval itself, i.e. FROST - // signing over admin changes. Today the list is set but every admin can - // still commit on their own, and the quorum the user picked has nowhere to - // live: MIP-01's wire format is fixed, so it cannot ride in MarmotGroupData - // without breaking byte-compatibility with mdk/whitenoise. It needs a - // ChatRoom column (and the Room migration that comes with it). - val adminPubkeys = when (selectedChatRoomType.value) { - ChatRoomType.CONVENIENT -> listOf(keyPair.pubKey.toHexKey()) - ChatRoomType.ROBUST -> (listOf(keyPair.pubKey.toHexKey()) + memberPublicKeys).distinct() + when (selectedChatRoomType.value) { + ChatRoomType.CONVENIENT -> createMarmotChatRoom(keyPair, onNavigateToRoute) + ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, onNavigateToRoute) } + } + + /** + * Convenient rooms are Marmot/MLS: one admin, forward secrecy, and members added + * by commit — which is why every invite needs the peer's key package first. + */ + private fun createMarmotChatRoom( + keyPair: KeyPair, + onNavigateToRoute: (Route) -> Unit + ) { + val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost... // Stamp initial metadata via the shared factory so UI + CLI stay // byte-identical. Bake the MarmotGroupData extension into the @@ -221,8 +220,6 @@ class SelectChatRoomTypeViewModel( outboxRelays = Relays.DefaultDMRelayList.map { it.url }, name = name, description = description.orEmpty() - ).copy( - adminPubkeys = adminPubkeys ) val extras = listOf(metadata.toExtension()) @@ -284,6 +281,52 @@ class SelectChatRoomTypeViewModel( } } + /** + * Robust rooms are NIP-17: no MLS group, no admin, no privileged member. Membership + * is simply the p-tag set every message carries, which is why there is nothing to + * 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. + */ + private fun createNip17ChatRoom( + keyPair: KeyPair, + onNavigateToRoute: (Route) -> Unit + ) { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.createNip17ChatRoom( + userPublicKey = keyPair.pubKey.toHexKey(), + participantPublicKeys = memberPublicKeys, + subject = name, + description = description + ) + + withContext(Dispatchers.Main) { + isActionPending.value = false + + if (localChatRoom == null) { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Something went wrong") + ) + return@withContext + } + + createdChatRoomId.value = localChatRoom.chatRoom.id + + onNavigateToRoute.invoke( + ChatRoomMessagingRoute( + activeUserPublicKey = keyPair.pubKey.toHexKey(), + chatRoomId = localChatRoom.chatRoom.id, + relayHint = null + ) + ) + } + } + } + /** * Adds every picked member to the freshly created group and returns the ones that * could not be added.