From de3b3556000c2d7f1cbdefd61631d4278a30818b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:08:17 +0200 Subject: [PATCH 01/10] feat: hand back a room's running ceremony rather than opening a second proposeRitual minted a fresh session on every call. Nothing called it twice for the same room, so nothing went wrong: the only way in was the shared-key screen, and canStartRitual() returns false while a session exists that has not failed. That is about to stop being true. A robust group opens a ceremony as it is created, and a NIP-17 room id is derived from its member set -- so making the same group again returns the same room and asks it again. The guard also sat in the wrong place regardless: on an observed UI snapshot, a screen away from the write it was protecting. ## What a second proposal costs It is not a duplicate row. ChillDKG hashes the participant set and the threshold into the session identity, so a second ceremony over the same room is a second `n` and `t` for every member to reconcile, and members join whichever proposal reaches them first -- relays hand gift wraps back in no particular order, so which one that is differs per device. The group ends up split across two ceremonies, neither of which can assemble the participant count it needs. Worse if the first one had finished. FrostSigningManager.completedKey falls back to getLatestSessionForChatRoom when the room has no signed key state and its id is not derived from the threshold key; a newer, unfinished session shadows the completed one there, and the group stops being able to reach the key it actually holds. ## The rule, and where it now lives The room's live ritual is returned as-is, so a caller gets a session either way and cannot tell whether it opened one. That is what makes the creation path safe to re-enter. The rule itself is unchanged -- it is the one canStartRitual() has always applied, right down to which stages block. It now also lives next to the write, where a stale snapshot cannot race it. FAILED is excluded deliberately: it is the one stage that does not hold the room's slot. A collapsed ceremony leaves the group with no key and a room they can still talk in, which is exactly the group that should be able to try again. Every other stage, COMPLETE included, is a ceremony the room depends on the outcome of. Checked before the require()s rather than after. A running ceremony settled the threshold question when it opened, so validating the argument would be validating an input with no effect -- and it would turn re-entering with a different quorum into an exception instead of the ceremony that exists. Co-Authored-By: Claude Opus 5 --- .../compose/managers/ChillDkgRitualManager.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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 cf20fd29..93386f5a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -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. From 0c240a31c81467179f546e4e7c607e8a1830e5e7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:08:44 +0200 Subject: [PATCH 02/10] feat: open a robust group's key ceremony as it is created Picking "robust" made a NIP-17 room and left it at that. The quorum the user had just set was read, explained, coerced into range -- and then dropped on the floor, with a TODO where it should have gone saying so: NIP-17 has no group state to change and so nothing to approve, and a different member set is simply a different room. That TODO had an answer the app has been able to give since ChillDkgRitual- Manager landed. The one thing a t-of-n rule can attach to here is a key the members generate together and cannot sign with unless t of n of them are present, and everything a ceremony needs is settled the moment the room exists: who is in it, and how many of them have to agree. So the room now opens one, and the proposal is its first message. ## Why at creation rather than behind the button The button is still there on the shared-key screen, and this changes nothing about it. What it cannot do is be found. A group that picked robust and got a plain NIP-17 room has the thing that makes it robust sitting one unmarked navigation away, and until somebody takes it the group's governance is a number nobody enforces. 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 -- there is no invite, no welcome, no key package -- so before this the first anyone learned of a robust group was whenever somebody happened to type into it. The proposal is now the first event out, and NostrDao already builds the room on the receiving side from a DKG payload's p-tags for exactly this reason. ## The order this runs in The room is created first, then the ceremony is proposed, then the screen navigates. createdChatRoomId is set the moment the room exists, before the proposal, so a second tap reuses that room rather than minting another -- and it is what freezes the type and quorum pickers, both of which are answered by then. Proposing before navigating means the chat opens with the ceremony already in it rather than filling in underneath the user. It costs no round trip: proposeRitual writes rows and queues a gift-wrap payload, and NotaryViewModel seals and broadcasts on its own schedule. The quorum is passed through as the threshold with no coercion. The screen derives its range from the picked members plus the creator, and createNip17ChatRoom stores exactly that set as the room's participants, so the range proposeRitual validates against is the same one the picker was bounded by. ## When the ceremony does not open Nothing is rolled back. The room is real, the group can talk in it, and the ceremony can be opened later from the group's details -- so failing the whole creation would be throwing away the part that worked. But it is not navigated past either. The screen stays put and says what happened, the way it already does when a Marmot group is created without some of its members; the button flips to "Open chat", which is what the user is left with. A robust group quietly without a key is the one outcome here worth interrupting for. ## Elsewhere The robust card's footnote now says that creating the group starts a key ceremony every member takes part in. Members are about to be asked to approve joining it, contributing to the key, and confirming the result, and none of that should be the first they hear of it. MantraNavHost hands the screen the DkgRepository it already builds for the ritual and approval routes; the preview takes the no-op. Left alone: DkgRitualViewModel still cannot read back the quorum a room was created with, because ChatRoom does not persist it. Its threshold picker re-derives a majority default, which now only matters for rooms made before this change or after a failed ceremony. Co-Authored-By: Claude Opus 5 --- .../ui/composable/SelectChatRoomTypeScreen.kt | 29 ++++++- .../ui/composable/navigation/MantraNavHost.kt | 1 + .../view/model/SelectChatRoomTypeViewModel.kt | 79 +++++++++++++++---- 3 files changed, 93 insertions(+), 16 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 27131b7b..3d840986 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -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, 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 = {} ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 9332b751..b72d072c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -543,6 +543,7 @@ fun MantraNavHost( activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, onNavigateToRoute = { chatRoomResultRoute -> navController.navigate( chatRoomResultRoute 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 038cd51f..fccb41f0 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 @@ -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, 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() + /** + * 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 = 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, 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 ) } } From 2089fdf8f025ede9e98a92b3f1cbb4921f7abbc4 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:09:06 +0200 Subject: [PATCH 03/10] test: cover the ceremony a robust room is created with 0c240a3 made a claim about a room's first message and de3b355 made one about its second ceremony, and neither is visible from any of the pieces already under test. ChillDkgRitualOrderingTest runs the ChillDKG calls, DkgSession- DaoJvmTest pins the state a ritual resumes from, NostrNip17DaoJvmTest covers the room. What none of them can see is a room and a ceremony together: that the room is empty until the ceremony, and that the ceremony is what fills it. This runs the real thing against a real database. Room's in-memory builder, the host's bundled SQLite, actual secp256k1 -- the room id is derived by doing point work over the member set, and each member's ChillDKG host key is derived from their nostr secret, so the keys are real KeyPairs rather than hex filler. Nothing is stubbed; createNip17ChatRoom and proposeRitual are called exactly as the view model calls them. jvmTest rather than commonTest because it needs a database. jvmTest goes 333 -> 336; commonTest is unchanged at 217. ## What is pinned - A robust room has no chat messages and nothing queued until the ceremony opens, and the first line in it afterwards is TYPE_DKG_STARTED. That is the whole of "the ceremony is the room's first message", stated as the before and the after rather than as a count. - The first event out is the 30310 proposal, authored by the creator. - It carries the whole membership. Receivers derive `n` from the p-tags plus the sender rather than from their own view of the room, so this is the claim that decides whether three devices can agree on one ceremony. - It carries the quorum that was picked, and the session id it opens. - The session runs at that quorum, over three participants, coordinated by the room's creator. - The creator's host key is already out, and hostKeyApprovedAt is set: opening a ceremony is the act of agreeing to be in it, so the member who opened it is not asked again. - Creating the same group twice returns the same room and the same ceremony -- asked for a different quorum the second time, and answered with the running one. One proposal on the wire, one line in the chat. - A FAILED ceremony is replaced rather than handed back, and the group proposes again. ## Checked against a mutation, not just run The re-entry test is the one that could pass for the wrong reason, so the guard it covers was deliberately broken -- `takeIf { false }`, which is de3b355 reverted -- and it failed on its own while the other two passed. Ordering is read off the autoincrement id rather than createdAt. Both chat rows are written inside one proposeRitual call and can land on the same timestamp, which would make an ORDER BY createdAt assertion pass or fail on timing. Co-Authored-By: Claude Opus 5 --- .../managers/RobustRoomKeyCeremonyTest.kt | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt new file mode 100644 index 00000000..b4808780 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt @@ -0,0 +1,224 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatMessage +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * What a group gets for choosing "robust": the key ceremony its room opens with. + * + * The quorum picked while creating the group has nothing in NIP-17 to govern -- + * there is 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 t-of-n key the + * members generate together. Everything that ceremony needs is settled the + * moment the room exists, so `SelectChatRoomTypeViewModel.createNip17ChatRoom` + * opens one there and then rather than leaving it for somebody to find a button + * for. These are the properties that has to hold to. + * + * The proposal being the room's *first* event is not a nicety. Standing up a + * NIP-17 room sends nothing to anybody, so until something goes out the group + * exists on one device only; the proposal is what the other members hear the + * room from at all, which is why `NostrDao` builds the room from a DKG payload's + * p-tags on the way in. + * + * The view model itself is not exercised here -- it needs a wallet's key manager + * and a Compose runtime -- so what stays uncovered is the wiring: that the + * quorum reaching [ChillDkgRitualManager.proposeRitual] is the one the picker + * holds, and that a null session leaves the user on the creation screen. + */ +class RobustRoomKeyCeremonyTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + // Real keys throughout: the room id is derived by doing point work over the + // member set, and the host key each member is identified by for the ceremony + // is derived from their nostr secret. Hex filler exercises neither. + private val creator = KeyPair() + private val user = creator.pubKey.toHexKey() + private val creatorPrivateKey = creator.privKey!! + private val alice = KeyPair().pubKey.toHexKey() + private val bob = KeyPair().pubKey.toHexKey() + + /** The quorum a three-member group is offered by default, and picks here. */ + private val quorum = 2 + + private suspend fun seedProfile(publicKey: String) { + val nostrEventId = publicKey.take(63) + "f" + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert( + Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId) + ) + } + + /** The room the creation screen leaves behind for a robust group. */ + private suspend fun robustRoom(): LocalChatRoom { + listOf(user, alice, bob).forEach { seedProfile(it) } + + return assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = listOf(alice, bob), + subject = "Robust Group", + ), + "createNip17ChatRoom returned null", + ) + } + + private suspend fun openCeremony(room: LocalChatRoom, threshold: Int = quorum) = + ChillDkgRitualManager.proposeRitual( + database = db, + localChatRoom = room, + userPublicKey = user, + nostrPrivateKey = creatorPrivateKey, + threshold = threshold, + ) + + private suspend fun ritualPayloads(room: LocalChatRoom) = + db.giftWrapPayloadDao().getByChatRoomAndKinds(room.chatRoom.id, DkgRitualEvents.ALL.toList()) + + /** The room's lines in the order they were written, id being the insertion order. */ + private suspend fun chatMessages(room: LocalChatRoom) = + db.chatMessageDao().getChatMessagesByChatRoomId(room.chatRoom.id) + .map { it.chatMessage } + .sortedBy { it.id } + + /** + * The whole of the change: a robust room has nothing in it until the ceremony, + * and the ceremony is the first thing in it. + */ + @Test + fun `a robust room's first message is the key ceremony it opens with`() = runBlocking { + val room = robustRoom() + + assertTrue(chatMessages(room).isEmpty(), "the room says nothing until something is sent") + assertTrue(ritualPayloads(room).isEmpty(), "and nothing has gone out yet") + + val session = openCeremony(room) + + assertEquals( + ChatMessage.TYPE_DKG_STARTED, + chatMessages(room).first().messageType, + "the first line in a robust room is its ceremony opening", + ) + + val proposal = ritualPayloads(room).first() + assertEquals(DkgRitualEvents.PROPOSAL, proposal.kind, "and the first event out is the proposal") + assertEquals(user, proposal.publicKey) + + // What every receiver rebuilds the ceremony from: `n` is the p-tag set plus + // the sender, and `t` is the quorum the group was created on. Both are read + // off this event rather than off the receiver's own view of the room, which + // is the thing that drifts. + assertEquals( + setOf(user, alice, bob), + proposal.participantPTags().map { it.pubKey }.toSet(), + "the proposal carries the whole membership", + ) + assertEquals(quorum, DkgRitualEvents.parseThreshold(proposal.tags)) + assertEquals(session.id, DkgRitualEvents.parseSessionId(proposal.tags)) + + assertEquals(quorum, session.threshold, "the ceremony runs at the quorum that was picked") + assertEquals(3, session.participantCount) + assertEquals(user, session.coordinatorPublicKey, "the room's creator coordinates") + + // Opening a ceremony is the act of agreeing to be in it, so this member is + // not asked again -- and their host key goes out with the proposal. + assertNotNull(session.hostKeyApprovedAt) + assertEquals( + 1, + ritualPayloads(room).count { it.kind == DkgRitualEvents.HOST_KEY }, + "the coordinator joins the ceremony it opened", + ) + } + + /** + * Creation is re-enterable -- the room id is derived from the member set, so the + * same group made twice is the same room -- and the ceremony has to be + * re-enterable with it. 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. + */ + @Test + fun `making the same group twice does not open a second ceremony`() = runBlocking { + val room = robustRoom() + val first = openCeremony(room) + + val again = assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = listOf(alice, bob), + subject = "Robust Group", + ) + ) + assertEquals(room.chatRoom.id, again.chatRoom.id, "the same members are the same room") + + // Asked for a different quorum this time: the running ceremony settled that + // question when it opened, and answers with itself rather than the ask. + val second = openCeremony(again, threshold = 3) + + assertEquals(first.id, second.id) + assertEquals(quorum, second.threshold) + assertEquals( + 1, + ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL }, + "one proposal, not two", + ) + assertEquals( + 1, + chatMessages(room).count { it.messageType == ChatMessage.TYPE_DKG_STARTED }, + "and the room is told once", + ) + } + + /** + * A collapsed ceremony leaves the group with no key and a room they can still + * talk in, which is exactly the group that should be able to try again. Failed + * is the one state that does not hold the room's ceremony slot. + */ + @Test + fun `a failed ceremony is replaced rather than handed back`() = runBlocking { + val room = robustRoom() + val abandoned = openCeremony(room) + + db.dkgSessionDao().upsert(abandoned.copy(stage = DkgRitualStage.FAILED)) + + val replacement = openCeremony(room) + + assertNotEquals(abandoned.id, replacement.id) + assertEquals(DkgRitualStage.COLLECTING_HOST_KEYS, replacement.stage) + assertEquals( + 2, + ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL }, + "the group proposes again", + ) + } +} From dff41d417d8553b0aee9d79c84907e3d625397ca Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:33:46 +0200 Subject: [PATCH 04/10] feat(frost): move a signing session's per-event columns onto FrostSigningItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the next phases follow. Schema only: a session still signs exactly one event, the wire is byte-identical, and every existing test passes on the moved columns. ## What moved, and why it had to A batch of k events is k independent FROST instances sharing a signer set, not one signature over k messages. That is forced rather than chosen: a Schnorr partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under one nonce R give two equations in one unknown and the secret share falls out. So the five columns that enter that equation -- unsignedEventJson, eventId, nonceRandom, aggregatedNonce, signature -- move to a child table keyed (sessionId, itemIndex). What stays on FrostSigningSession is everything outside it: the ceremony, the threshold, the derivation path, the signer set, and the one approval. itemIndex is protocol rather than presentation -- nonces and partial signatures are joined positionally against it -- so getItems() orders by it and nothing re-sorts. Spelled itemIndex rather than index to keep hand-written queries free of backticks. No itemCount column. The count is a COUNT(*), for the same reason signerIds is derived from the ceremony's participant order rather than stored: a denormalised count is one more thing that can disagree with the rows. ## Migration 9 -> 10 Manual, not auto: Room can create the table and drop the columns but cannot copy between them, and the copy is the whole point. A session in flight at upgrade holds its nonce seed and the aggregate it is already signing against, and neither can be regenerated -- losing either makes the next pass derive a different nonce for the same message and publish a second partial signature over it, which is the extraction case. Both are copied verbatim into item 0, so an in-flight session resumes as though nothing happened. Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires cascades -- with foreign keys enforced the rebuild would delete every signer message and every item just written. Whether it does depends on Room disabling foreign keys around migrations, which is not worth depending on when DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed, unconstrained columns; these five qualify, and getRoomDatabase pins BundledSQLiteDriver on every platform. ## Invariants established here for the phases that follow - signerIds and every item's aggregatedNonce are one write-once unit, applied by applyAggregate() -- items first in one transaction, then the session, so "some items aggregated" is unreachable and signerIds != null stays the gate. - Signatures likewise, via applySignatures(); isSigned() counts rows instead of reading a flag. - complete() verifies every signature before applying any event, so a batch is all-or-nothing rather than half-filed. - itemsOver() gives each item its own 32 bytes of seed. Independent seeds mean an off-by-one in index handling produces a session that fails to aggregate rather than one that signs two messages under a single nonce. signedEvent() and isAwaitingApproval() now take the item(s) rather than the session, which propagates to the repository, the view model and the screen. advance() reads items.first() and Phase 2 turns that into a loop. ## Tests - FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert replacing rather than accumulating, signed-item counting, cascade delete. - FrostSigningItemMigrationJvmTest (new): the backfill against a real v9 database, asserting the seed and aggregate values survive -- not merely that a row appeared -- plus the exact column lists Room will check at open time. - 338 jvmTest and 217 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 --- .../10.json | 5443 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 9 +- .../builder/PlatformDatabaseBuilder.kt | 9 +- .../database/dao/FrostSigningSessionDao.kt | 42 + .../migrations/FrostSigningItemMigration.kt | 90 + .../database/model/FrostSigningItem.kt | 88 + .../database/model/FrostSigningSession.kt | 78 +- .../DatabaseFrostSigningRepository.kt | 11 +- .../compose/managers/FrostSigningManager.kt | 260 +- .../repository/FrostSigningRepository.kt | 23 +- .../ui/composable/FrostSigningScreen.kt | 4 +- .../ui/view/model/FrostSigningViewModel.kt | 19 +- .../ui/view/state/FrostSigningUIState.kt | 7 + .../compose/managers/FrostSigningRoundTest.kt | 71 +- .../compose/managers/GroupKeyStateTest.kt | 17 +- .../managers/SharedKeyDerivationTest.kt | 5 +- .../compose/managers/SignedArtifactTest.kt | 31 +- .../dao/FrostSigningSessionDaoJvmTest.kt | 117 +- .../FrostSigningItemMigrationJvmTest.kt | 186 + .../managers/SignedGroupKeyStateTest.kt | 28 +- docs/README.md | 3 + docs/frost-batch-signing.md | 450 ++ 22 files changed, 6788 insertions(+), 203 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt create mode 100644 docs/frost-batch-signing.md diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json new file mode 100644 index 00000000..0d184295 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json @@ -0,0 +1,5443 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "ab781e3489c1258d2dc39c2b2f35cb61", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab781e3489c1258d2dc39c2b2f35cb61')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index 9baaaeb7..4f62d523 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -106,6 +106,7 @@ import press.mantra.compose.database.model.RecentSearch import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.Relay import press.mantra.compose.database.model.RepostedRelation @@ -130,6 +131,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) DkgParticipantMessage::class, DkgSession::class, FrostSignerMessage::class, + FrostSigningItem::class, FrostSigningSession::class, GiftWrapMessage::class, GiftWrapSeal::class, @@ -172,7 +174,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 9, + version = 10, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -215,6 +217,11 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // is what they were signing as, so one caught mid-flight finishes the way // it began rather than switching keys between two of its own rounds. AutoMigration(from = 8, to = 9) + // v10 moves FrostSigningSession's five per-event columns onto the new + // FrostSigningItem table, so one session can sign a batch. It copies + // before it drops, which no AutoMigration can express -- see + // MIGRATION_9_10, and the note there on why an in-flight session losing + // its nonce seed would be worse than losing the session. ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt index 50bf1dbc..92b31069 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt @@ -3,6 +3,7 @@ package press.mantra.compose.database.builder import androidx.room3.RoomDatabase import androidx.sqlite.driver.bundled.BundledSQLiteDriver import press.mantra.compose.database.migrations.MIGRATION_3_4 +import press.mantra.compose.database.migrations.MIGRATION_9_10 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -16,9 +17,11 @@ fun getRoomDatabase( builder: RoomDatabase.Builder ): press.mantra.compose.database.MantraDatabase { return builder - // Everything else Room generates itself; this one rewrites rows rather than - // changing shape, which an AutoMigration cannot express. - .addMigrations(MIGRATION_3_4) + // Everything else Room generates itself. These two move data rather than + // only changing shape, which an AutoMigration cannot express: 3->4 rewrites + // chat rows, and 9->10 copies a session's per-event columns onto the items + // table before dropping them. + .addMigrations(MIGRATION_3_4, MIGRATION_9_10) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt index 0b68a2b2..bc6ba0d0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt @@ -2,11 +2,13 @@ package press.mantra.compose.database.dao import androidx.room3.Dao import androidx.room3.Query +import androidx.room3.Transaction import androidx.room3.Upsert import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import kotlinx.coroutines.flow.Flow import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession @Dao @@ -36,6 +38,46 @@ interface FrostSigningSessionDao { @Upsert suspend fun upsert(frostSignerMessage: FrostSignerMessage) + /** + * A session's events, in the order the batch fixed at proposal. + * + * Every join of nonces and partial signatures is positional, so this + * ordering is part of the protocol rather than a presentation choice — + * two devices reading a batch in different orders aggregate against + * different messages. + */ + @Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC") + suspend fun getItems(sessionId: String): List + + @Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC") + fun observeItems(sessionId: String): Flow> + + @Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId AND itemIndex = :itemIndex") + suspend fun getItem(sessionId: String, itemIndex: Int): FrostSigningItem? + + @Upsert + suspend fun upsert(frostSigningItem: FrostSigningItem) + + /** + * Writes a whole batch's worth of items at once. + * + * A transaction rather than a loop because the coordinator's two broadcasts + * each settle every item together: a half-written aggregate would leave the + * session gated on `signerIds` while some items had no nonce to aggregate + * against, which is a stall no message can clear. + */ + @Transaction + @Upsert + suspend fun upsertItems(items: List) + + /** How many events this session signs. Derived rather than stored — see [FrostSigningItem]. */ + @Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId") + suspend fun countItems(sessionId: String): Int + + /** How many of them the group has finished. Equal to [countItems] once it is done. */ + @Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId AND signature IS NOT NULL") + suspend fun countSignedItems(sessionId: String): Int + @Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC") suspend fun getMessagesByKind(sessionId: String, kind: Kind): List diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt new file mode 100644 index 00000000..3b247268 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt @@ -0,0 +1,90 @@ +package press.mantra.compose.database.migrations + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Moves a signing session's per-event columns onto `FrostSigningItem`, so a + * session can sign more than one event. + * + * Not an `AutoMigration`. Room can create the table and it can drop the columns, + * but it cannot copy between them, and this migration is entirely about the copy. + * + * ### Why the backfill has to be exact + * + * A session in flight at upgrade time holds two things that cannot be + * regenerated: `nonceRandom`, the seed its secret nonce is derived from, and + * `aggregatedNonce`, the aggregate its partial signature is made against. Lose + * the seed and the next pass derives a *different* nonce for the same message, + * then publishes a second partial signature over it -- two partial signatures, + * one message, two nonces, which is precisely how a secret share is extracted. + * Lose the aggregate and the session republishes against a new one, with the + * same result. + * + * So this copies them verbatim into item 0, and an in-flight session resumes as + * though nothing happened. Dropping the columns and letting sessions start over + * would have been the one dangerous way to write this migration. + * + * `signerIds` stays on the session: it is shared by every item of a batch, and + * an upgraded session keeps whichever set it had chosen. + * + * ### Why `DROP COLUMN` rather than a table rebuild + * + * The usual way to remove columns in SQLite -- create a new table, copy, drop + * the old one, rename -- is unsafe here and quietly so. `FrostSignerMessage` and + * the new `FrostSigningItem` both reference `FrostSigningSession(id)` + * `ON DELETE CASCADE`, and `DROP TABLE` fires cascades: with foreign keys + * enforced it would delete every signer message and every item this migration + * had just written. It would depend entirely on Room having turned foreign keys + * off around the migration, which is not a thing worth depending on when there + * is an alternative that cannot go wrong. + * + * `ALTER TABLE ... DROP COLUMN` is that alternative. It needs SQLite 3.35, and + * the columns must be free of indices and constraints -- these five are, and + * `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version + * is ours rather than the host's. The table, its foreign key and both its + * indices are left alone. + */ +val MIGRATION_9_10 = object : Migration(9, 10) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `FrostSigningItem` (" + + "`sessionId` TEXT NOT NULL, " + + "`itemIndex` INTEGER NOT NULL, " + + "`unsignedEventJson` TEXT NOT NULL, " + + "`eventId` TEXT NOT NULL, " + + "`nonceRandom` TEXT NOT NULL, " + + "`aggregatedNonce` TEXT, " + + "`signature` TEXT, " + + "PRIMARY KEY(`sessionId`, `itemIndex`), " + + "FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` " + + "ON `FrostSigningItem` (`sessionId`)" + ) + + // Every existing session signed exactly one event, so every one of them + // becomes a batch of one at index 0. + connection.execSQL( + "INSERT INTO `FrostSigningItem` " + + "(`sessionId`, `itemIndex`, `unsignedEventJson`, `eventId`, " + + "`nonceRandom`, `aggregatedNonce`, `signature`) " + + "SELECT `id`, 0, `unsignedEventJson`, `eventId`, " + + "`nonceRandom`, `aggregatedNonce`, `signature` " + + "FROM `FrostSigningSession`" + ) + + listOf( + "unsignedEventJson", + "eventId", + "nonceRandom", + "aggregatedNonce", + "signature", + ).forEach { column -> + connection.execSQL("ALTER TABLE `FrostSigningSession` DROP COLUMN `$column`") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt new file mode 100644 index 00000000..8ebc6534 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt @@ -0,0 +1,88 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * One event a signing session signs, and everything about it that cannot be + * shared with the others. + * + * A session signs a *batch*: one ceremony, one signer set, one approval, and `n` + * events. What separates them is forced by FROST rather than chosen. A Schnorr + * partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under + * one nonce `R` give two equations in one unknown and the secret share falls + * out. Every item therefore carries its own nonce material and its own result, + * and the only things a batch may hold in common are the ones that do not enter + * that equation -- see [FrostSigningSession]. + * + * These five columns lived on [FrostSigningSession] until the schema moved to + * v10, which is why a session created before it reads back as exactly one item + * at [itemIndex] 0. + */ +@Entity( + primaryKeys = ["sessionId", "itemIndex"], + foreignKeys = [ + ForeignKey( + entity = FrostSigningSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("sessionId"), + ], +) +data class FrostSigningItem( + val sessionId: String, + + /** + * Position in the batch, from zero. + * + * Load-bearing rather than cosmetic: it is the order every device joins + * nonces and partial signatures in, so two devices that disagree about it + * aggregate against different messages and produce a signature nobody can + * verify. Fixed by the proposal and never re-sorted. + * + * Named `itemIndex` rather than `index` because `index` needs quoting in + * every hand-written query it appears in, and one missing backtick is a + * compile error at best. + */ + val itemIndex: Int, + + /** + * The unsigned event, as JSON. Kept whole so a member can be shown what + * they are being asked to sign rather than a hash of it. + */ + val unsignedEventJson: String, + + /** + * The event id, which is the 32 bytes actually signed. + * + * Recomputed from the event's own fields on arrival, never taken from the + * proposal. It pins this item to one message -- which is what makes reusing + * [nonceRandom] safe, exactly as it was when a session signed one event. + */ + val eventId: HexKey, + + /** Secret. 32 bytes of fresh randomness, the seed for this item's nonce. */ + val nonceRandom: HexKey, + + /** + * The coordinator's `AggregatedNonce` for this item once it arrives, hex. + * + * Written once, and written for every item of the batch in one transaction + * alongside [FrostSigningSession.signerIds] -- so "some items aggregated" is + * a state that cannot be reached, and `signerIds != null` is the one gate + * the rest of the session reads. + */ + val aggregatedNonce: HexKey? = null, + + /** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */ + val signature: HexKey? = null, +) { + /** Whether the group has produced this item's signature. */ + fun isSigned(): Boolean = signature != null +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt index e823ec1f..e1d43bf5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt @@ -21,22 +21,40 @@ import press.mantra.compose.managers.SharedKeyDerivation * convenient here, it is forced -- `fr.acinq.bitcoin.crypto.frost.SecretNonce` * cannot be serialised and refuses to be used twice, by design. * - * ### The nonce, and why one session means one message + * ### One session, several events * - * [nonceRandom] is secret, and regenerating this device's nonce from it is safe - * for exactly one reason: a session signs one message and can never be made to - * sign another. `SecretNonce.generate` mixes the message in, so the same - * randomness under a different message would be a different nonce -- but the - * same randomness under the same message with two *different* aggregated nonces - * would produce two partial signatures over one secret nonce, which is how a - * secret share is extracted. + * A session signs a batch of one or more events, held as [FrostSigningItem] + * rows. What lives here is what the whole batch shares, and the split is not a + * matter of taste: an item's nonce, message and signature are the terms of + * `s = k + e·x` with `e = H(R‖P‖m)`, so sharing any of them across two messages + * is how a secret share is extracted. Everything on this row is outside that + * equation. + * + * | shared, and so here | per item, and so on [FrostSigningItem] | + * |---|---| + * | ceremony, threshold, participant count | the unsigned event and its id | + * | [signerIds] and the signer set | nonce seed, and the nonce from it | + * | [derivationPath] and its tweak cache | aggregated nonce | + * | [signApprovedAt] -- one decision | the signature | + * + * ### The nonce, and why one item means one message + * + * [FrostSigningItem.nonceRandom] is secret, and regenerating this device's nonce + * from it is safe for exactly one reason: an item signs one message and can + * never be made to sign another. `SecretNonce.generate` mixes the message in, so + * the same randomness under a different message would be a different nonce -- + * but the same randomness under the same message with two *different* aggregated + * nonces would produce two partial signatures over one secret nonce, which is + * how a secret share is extracted. * * Two rules keep that impossible, and both are load-bearing: * - * - [eventId] is written when the session is created and a proposal that - * disagrees with it is rejected rather than applied. - * - [aggregatedNonce] and [signerIds] are written once. A second, different - * signer set for the same session is ignored, not honoured. + * - [FrostSigningItem.eventId] is written when the session is created, and a + * proposal that disagrees with the batch it already holds is rejected rather + * than applied. + * - [FrostSigningItem.aggregatedNonce] and [signerIds] are written once, and + * written together. A second, different signer set for the same session is + * ignored, not honoured. */ @Entity( foreignKeys = [ @@ -92,38 +110,16 @@ data class FrostSigningSession( * it is running in. Null is the honest answer for a room that is not derived * from the key at all, and is what sessions predating this column read back * as -- both of which signed as the threshold key itself. + * + * Shared by the whole batch: every item of a session signs as the same room. */ val derivationPath: String? = null, val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, - /** - * The unsigned event, as JSON. Kept whole so a member can be shown what - * they are being asked to sign rather than a hash of it. - */ - val unsignedEventJson: String, - - /** - * The event id, which is the 32 bytes actually signed. - * - * Recomputed from the event's own fields on arrival, never taken from the - * proposal. It pins the session to one message -- see the class note on why - * that is what makes reusing [nonceRandom] safe. - */ - val eventId: HexKey, - - /** Secret. 32 bytes of fresh randomness, the seed for this device's nonce. */ - val nonceRandom: HexKey, - - /** The coordinator's `AggregatedNonce` once it arrives, hex. Written once. */ - val aggregatedNonce: HexKey? = null, - /** The chosen signers' FROST ids in aggregation order, comma separated. Written once. */ val signerIds: String? = null, - /** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */ - val signature: HexKey? = null, - val failureReason: String? = null, /** @@ -131,10 +127,12 @@ data class FrostSigningSession( * session does on their behalf. Null until they do, and nothing of theirs * goes out before it is set. * - * One gate rather than the DKG's three. What a signer is consenting to is - * the event, and the event is fixed before they are asked: the second round - * puts no new question to them, so asking again would be asking the same - * question twice about a decision already made. + * One gate rather than the DKG's three, and one gate for the whole batch + * rather than one per item. What a signer is consenting to is the events, + * and the events are fixed before they are asked: the second round puts no + * new question to them, so asking again would be asking the same question + * twice about a decision already made. That holds for a batch only as long + * as the member can actually see every event in it before answering. */ val signApprovedAt: Instant? = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt index abd6ed08..5a7b00b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.FrostSigningStage @@ -29,6 +30,12 @@ class DatabaseFrostSigningRepository( override fun observeMessages(sessionId: String): Flow> = database.frostSigningSessionDao().observeMessages(sessionId) + override fun observeItems(sessionId: String): Flow> = + database.frostSigningSessionDao().observeItems(sessionId) + + override suspend fun getItems(sessionId: String): List = + database.frostSigningSessionDao().getItems(sessionId) + override suspend fun getSessionById(sessionId: String): FrostSigningSession? = database.frostSigningSessionDao().getSessionById(sessionId) @@ -92,8 +99,8 @@ class DatabaseFrostSigningRepository( } } - override fun signedEvent(session: FrostSigningSession): Event? = - FrostSigningManager.signedEvent(session) + override fun signedEvents(items: List): List = + FrostSigningManager.signedEvents(items) companion object { private const val TAG = "DatabaseFrostSigningRepository" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 6927d41e..240d56bf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -23,6 +23,7 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.MarmotInnerEvent @@ -145,15 +146,11 @@ object FrostSigningManager { participantCount = ceremony.participantCount, signerId = signerId, derivationPath = path?.let(SharedKeyDerivation::formatPath), - unsignedEventJson = unsignedEvent.toJson(), - eventId = unsignedEvent.id, - // Fresh per session, and never reused: this is the seed the device's - // secret nonce is regenerated from for the life of the session. - nonceRandom = RandomInstance.bytes(32).toHex(), // Proposing a signature is already the act of agreeing to it. signApprovedAt = Clock.System.now() ) database.frostSigningSessionDao().upsert(session) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent))) announceStarted(database, session) logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${ceremony.id}") @@ -163,7 +160,7 @@ object FrostSigningManager { localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.PROPOSAL, - content = session.unsignedEventJson, + content = unsignedEvent.toJson(), includeKey = true ) @@ -236,15 +233,16 @@ object FrostSigningManager { userPublicKey: HexKey ): FrostSigningSession? { database.frostSigningSessionDao().getSessionById(sessionId)?.let { existing -> - // The message a session signs is fixed at creation. A second proposal - // under the same id carrying a different event is either a mistake or - // an attempt to get two signatures out of one secret nonce, which is - // how a share is extracted -- so it is refused, not applied. + // The events a session signs are fixed at creation. A second proposal + // under the same id carrying different ones is either a mistake or an + // attempt to get two signatures out of one secret nonce, which is how + // a share is extracted -- so it is refused, not applied. val proposed = Event.fromJsonOrNull(innerEvent.content) - if (proposed != null && proposed.id != existing.eventId) { + val signing = database.frostSigningSessionDao().getItems(sessionId).map { it.eventId } + if (proposed != null && signing != listOf(proposed.id)) { logger.w( "Session $sessionId re-proposed with event ${proposed.id}, " + - "but it is already signing ${existing.eventId}; ignoring" + "but it is already signing ${signing.joinToString()}; ignoring" ) } return existing @@ -312,12 +310,10 @@ object FrostSigningManager { threshold = key.threshold, participantCount = key.participantCount, signerId = signerId, - derivationPath = path?.let(SharedKeyDerivation::formatPath), - unsignedEventJson = unsignedEvent.toJson(), - eventId = unsignedEvent.id, - nonceRandom = RandomInstance.bytes(32).toHex() + derivationPath = path?.let(SharedKeyDerivation::formatPath) ) database.frostSigningSessionDao().upsert(session) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent))) announceStarted(database, session) logger.i("Recorded signing session $sessionId over ${unsignedEvent.id}; awaiting approval") @@ -406,26 +402,19 @@ object FrostSigningManager { return true } - val known = current(database, session).aggregatedNonce != null - // Write-once, and this is the load-bearing one. Signing the same // message twice under one secret nonce against two different // aggregated nonces is exactly how a secret share is extracted, so // a coordinator that sends a second, different signer set is // ignored rather than obeyed. The session stalls; the share does // not leak. - update(database, session) { current -> - if (current.aggregatedNonce == null) { - current.copy( - aggregatedNonce = innerEvent.content, - signerIds = signerIds.joinToString(",") - ) - } else { - current - } - } + // + // `signerIds` is the gate for the whole batch: it and every item's + // aggregated nonce are written together, so a session holding one + // holds all of them. + if (current(database, session).signerIds != null) return true - if (!known) { + if (applyAggregate(database, session, signerIds, listOf(innerEvent.content))) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -433,17 +422,12 @@ object FrostSigningManager { FrostSigningEvents.SIGNATURE -> { if (!isFromCoordinator(session, innerEvent)) return true - val known = current(database, session).signature != null + // Write-once as a unit, for the same reason the aggregate is: a + // batch that had some of its signatures would be one the group + // could neither finish nor safely retry. + if (isSigned(database, session)) return true - update(database, session) { current -> - if (current.signature == null) { - current.copy(signature = innerEvent.content) - } else { - current - } - } - - if (!known) { + if (applySignatures(database, session, listOf(innerEvent.content))) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -504,7 +488,7 @@ object FrostSigningManager { // there -- while going on to ask would be asking for a decision that // can no longer change anything, and would let a late "don't sign" // abandon a signature that exists. - if (session.signature != null) { + if (isSigned(database, session)) { complete(database, session) return } @@ -526,14 +510,19 @@ object FrostSigningManager { val tweakCache = SharedKeyDerivation .derive(thresholdPublicKey, session.pathIndices()) .cache - val message = ByteVector(session.eventId.hexToByteArray()) val publicShares = key.publicShareList() + // The events this session signs, in the order the proposal fixed. Every + // join below is positional against it. + val items = database.frostSigningSessionDao().getItems(sessionId) + val item = items.firstOrNull() ?: return + val message = ByteVector(item.eventId.hexToByteArray()) + // Regenerated rather than stored -- SecretNonce refuses both. Safe - // because the session's message can never change; see the notes on + // because an item's message can never change; see the notes on // FrostSigningSession. val (secretNonce, publicNonce) = SecretNonce.generate( - sessionRandom = ByteVector32(session.nonceRandom), + sessionRandom = ByteVector32(item.nonceRandom), secretShare = secretShare, publicShare = publicShares?.getOrNull(session.signerId), tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, @@ -551,26 +540,26 @@ object FrostSigningManager { ) } - if (session.isCoordinator() && session.aggregatedNonce == null) { + if (session.isCoordinator() && session.signerIds == null) { val offered = orderedNonces(database, session) ?: return val chosen = offered.take(session.threshold) val aggregated = IndividualNonce.aggregate(chosen.map { it.second }) .orThrow("aggregating nonces") - session = update(database, session) { - it.copy( - aggregatedNonce = aggregated.toByteArray().toHex(), - signerIds = chosen.joinToString(",") { (id, _) -> id.toString() } - ) + val chosenIds = chosen.map { (id, _) -> id } + if (!applyAggregate(database, session, chosenIds, listOf(aggregated.toByteArray().toHex()))) { + return } + session = current(database, session) + broadcast( database = database, localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.SIGNER_SET, content = aggregated.toByteArray().toHex(), - signerIds = chosen.map { (id, _) -> id } + signerIds = chosenIds ) announceStep( database, @@ -580,8 +569,9 @@ object FrostSigningManager { ) } - val aggregatedNonce = session.aggregatedNonce ?: return val signerIds = session.signerIdList() ?: return + val aggregatedNonce = database.frostSigningSessionDao() + .getItem(sessionId, item.itemIndex)?.aggregatedNonce ?: return session = moveTo(database, session, FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES) val signingSession = Session.create( @@ -613,7 +603,7 @@ object FrostSigningManager { ) } - if (session.isCoordinator() && session.signature == null) { + if (session.isCoordinator() && !isSigned(database, session)) { val partials = orderedPartialSignatures(database, session, signerIds) ?: return val signature = signingSession @@ -621,7 +611,9 @@ object FrostSigningManager { .orThrow("aggregating partial signatures") .toHex() - session = update(database, session) { it.copy(signature = signature) } + if (!applySignatures(database, session, listOf(signature))) return + session = current(database, session) + broadcast( database = database, localChatRoom = localChatRoom, @@ -662,16 +654,25 @@ object FrostSigningManager { * like every other line in the transcript. */ private suspend fun complete(database: MantraDatabase, session: FrostSigningSession) { - val signature = session.signature ?: return + val items = database.frostSigningSessionDao().getItems(session.id) + if (items.isEmpty() || items.any { !it.isSigned() }) return - val signedEvent = signedEvent(session, signature) - val verified = Nip01Crypto.verify( - signature = signature.hexToByteArray(), - hash = session.eventId.hexToByteArray(), - pubKey = signedEvent.pubKey.hexToByteArray() - ) - if (!verified) { - throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}") + // Every signature is checked before any event is applied. A batch is + // all-or-nothing, so a bad one anywhere has to fail the session rather + // than leave some of its events already filed. + val signedEvents = items.map { item -> + val signedEvent = signedEvent(item, item.signature!!) + val verified = Nip01Crypto.verify( + signature = item.signature.hexToByteArray(), + hash = item.eventId.hexToByteArray(), + pubKey = signedEvent.pubKey.hexToByteArray() + ) + if (!verified) { + throw IllegalStateException( + "The aggregated signature does not verify against ${item.eventId}" + ) + } + signedEvent } update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } @@ -683,14 +684,14 @@ object FrostSigningManager { // wire: a signed event authored by the threshold key cannot travel as // an inner event anyway, because the outbound pipeline re-authors // rumors as their sender and would strip the group's signature off. - applySignedEvent(database, session, signedEvent) + signedEvents.forEach { applySignedEvent(database, session, it) } announce( database = database, session = session, messageType = ChatMessage.TYPE_FROST_COMPLETE, - content = "The group signed the event. It took ${session.threshold} of " + - "${session.participantCount} members.", + content = "The group signed ${describe(signedEvents.size)}. It took " + + "${session.threshold} of ${session.participantCount} members.", actor = session.coordinatorPublicKey ) @@ -736,8 +737,8 @@ object FrostSigningManager { * Public so a caller can take the finished event and do whatever it was * signing it for — the session's job ends at a valid signature. */ - fun signedEvent(session: FrostSigningSession, signature: HexKey): Event { - val unsigned = Event.fromJson(session.unsignedEventJson) + fun signedEvent(item: FrostSigningItem, signature: HexKey): Event { + val unsigned = Event.fromJson(item.unsignedEventJson) return Event( id = unsigned.id, @@ -750,9 +751,115 @@ object FrostSigningManager { ) } - /** The finished event, or null while the session is still running. */ - fun signedEvent(session: FrostSigningSession): Event? = - session.signature?.let { signedEvent(session, it) } + /** The finished event, or null while this item is still running. */ + fun signedEvent(item: FrostSigningItem): Event? = + item.signature?.let { signedEvent(item, it) } + + /** Every finished event of a batch, empty while any of them is still running. */ + fun signedEvents(items: List): List = + items.map { it.signature ?: return emptyList() } + .mapIndexed { index, signature -> signedEvent(items[index], signature) } + + /** + * The items a session signs: one per event, each with its own nonce seed. + * + * Independent seeds rather than one derived per index, which would work and + * save nothing worth having. Independence means an off-by-one anywhere in + * the index handling produces a session that fails to aggregate, instead of + * one that signs two messages under a single nonce. + */ + private fun itemsOver(sessionId: String, events: List): List = + events.mapIndexed { index, event -> + FrostSigningItem( + sessionId = sessionId, + itemIndex = index, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = RandomInstance.bytes(32).toHex() + ) + } + + /** + * Records the coordinator's signer set and the aggregated nonce each item is + * signed against, as one write. Returns false when the message does not fit + * the batch, so the caller neither announces nor acts on it. + * + * The two belong together. [FrostSigningSession.signerIds] is what the rest + * of the session gates on, so a session holding it while an item still had no + * aggregate would stall on a message that has already been delivered — and + * the write that cleared the stall would be a second aggregate for an item + * that had one, which is the case the write-once rule exists to prevent. + * Items first, in one transaction, then the session: the gate is only ever + * set on a batch that is entirely ready. + */ + private suspend fun applyAggregate( + database: MantraDatabase, + session: FrostSigningSession, + signerIds: List, + aggregatedNonces: List + ): Boolean { + val items = database.frostSigningSessionDao().getItems(session.id) + if (items.isEmpty() || items.size != aggregatedNonces.size) { + logger.w( + "Session ${session.id}: signer set carries ${aggregatedNonces.size} " + + "aggregated nonce(s) for ${items.size} item(s); ignoring" + ) + return false + } + + database.frostSigningSessionDao().upsertItems( + items.mapIndexed { index, item -> item.copy(aggregatedNonce = aggregatedNonces[index]) } + ) + update(database, session) { it.copy(signerIds = signerIds.joinToString(",")) } + + return true + } + + /** + * Records the group's finished signatures, all of them or none. Returns false + * when the message does not fit the batch. + * + * Settled as a unit for the same reason the aggregate is: a batch holding + * some of its signatures is one the group can neither finish nor safely + * retry, since a retry means fresh nonces for events that already have a + * signature against the old ones. + */ + private suspend fun applySignatures( + database: MantraDatabase, + session: FrostSigningSession, + signatures: List + ): Boolean { + val items = database.frostSigningSessionDao().getItems(session.id) + if (items.isEmpty() || items.size != signatures.size) { + logger.w( + "Session ${session.id}: ${signatures.size} signature(s) for " + + "${items.size} item(s); ignoring" + ) + return false + } + + database.frostSigningSessionDao().upsertItems( + items.mapIndexed { index, item -> item.copy(signature = signatures[index]) } + ) + + return true + } + + /** + * Whether the group has produced every signature this session was opened for. + * + * Counted rather than flagged, so it cannot disagree with the rows it + * describes. A session with no items is not signed — it is one whose proposal + * has not landed yet. + */ + private suspend fun isSigned(database: MantraDatabase, session: FrostSigningSession): Boolean { + val total = database.frostSigningSessionDao().countItems(session.id) + + return total > 0 && database.frostSigningSessionDao().countSignedItems(session.id) == total + } + + /** "the event" or "3 events", for a transcript line that reads the same at either size. */ + private fun describe(count: Int): String = if (count == 1) "the event" else "$count events" /** * The nonces on offer, as (signer id, nonce), ordered by signer id — or null @@ -1091,7 +1198,10 @@ object FrostSigningManager { * it. The two must agree, or the screen offers an approval that does nothing, * or none while the session sits still. */ - fun isAwaitingApproval(session: FrostSigningSession): Boolean { + fun isAwaitingApproval( + session: FrostSigningSession, + items: List + ): Boolean { if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) { return false } @@ -1100,7 +1210,7 @@ object FrostSigningManager { // the session on its next pass without asking them anything. Offering the // decision anyway would be offering two bad answers: a nonce nobody is // waiting for, or a refusal that abandons a signature already made. - if (session.signature != null) return false + if (items.isNotEmpty() && items.all { it.isSigned() }) return false return session.signApprovedAt == null } @@ -1118,8 +1228,9 @@ object FrostSigningManager { sessionId: String ) { val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + val items = database.frostSigningSessionDao().getItems(sessionId) - if (!isAwaitingApproval(session)) { + if (!isAwaitingApproval(session, items)) { logger.i("Session $sessionId is not waiting on an approval; ignoring it") return } @@ -1145,8 +1256,9 @@ object FrostSigningManager { sessionId: String ) { val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + val items = database.frostSigningSessionDao().getItems(sessionId) - if (!isAwaitingApproval(session)) return + if (!isAwaitingApproval(session, items)) return fail( database = database, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt index 44d4a534..66abf28e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -6,6 +6,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -24,6 +25,17 @@ interface FrostSigningRepository { fun observeMessages(sessionId: String): Flow> + /** + * The events a session signs, in the order its proposal fixed. + * + * Separate from the session because a session signs a batch, and what + * separates one event of it from another is forced by FROST rather than + * chosen — see [FrostSigningItem]. + */ + fun observeItems(sessionId: String): Flow> + + suspend fun getItems(sessionId: String): List + suspend fun getSessionById(sessionId: String): FrostSigningSession? /** @@ -58,8 +70,8 @@ interface FrostSigningRepository { /** Refuses, and says so, since a t-of-n group can proceed without this member. */ suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) - /** The finished event, or null while the session is still running. */ - fun signedEvent(session: FrostSigningSession): Event? + /** The finished events, or empty while any of them is still running. */ + fun signedEvents(items: List): List companion object { val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository { @@ -71,6 +83,11 @@ interface FrostSigningRepository { override fun observeMessages(sessionId: String): Flow> = flowOf(emptyList()) + override fun observeItems(sessionId: String): Flow> = + flowOf(emptyList()) + + override suspend fun getItems(sessionId: String): List = emptyList() + override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null @@ -89,7 +106,7 @@ interface FrostSigningRepository { override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit - override fun signedEvent(session: FrostSigningSession): Event? = null + override fun signedEvents(items: List): List = emptyList() } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 2b7730a5..2480394e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -146,7 +146,7 @@ fun FrostSigningScreen( .padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp) ) { - WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session)) + WhatIsBeingSigned(frostSigningViewModel.proposedEvents(state.items).firstOrNull()) HorizontalDivider() @@ -229,7 +229,7 @@ fun FrostSigningScreen( // Asked rather than re-derived: the manager owns when a session // is still waiting on its owner, and a second copy of that rule // here is a second copy to keep in step. - if (FrostSigningManager.isAwaitingApproval(session)) { + if (FrostSigningManager.isAwaitingApproval(session, state.items)) { HorizontalDivider() Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt index b2942491..6a1d0afa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.repository.ChatRepository @@ -66,12 +67,14 @@ class FrostSigningViewModel( combine( frostSigningRepository.observeSessionById(id), - frostSigningRepository.observeMessages(id) - ) { session, messages -> session to messages } - .collect { (session, messages) -> + frostSigningRepository.observeMessages(id), + frostSigningRepository.observeItems(id) + ) { session, messages, items -> Triple(session, messages, items) } + .collect { (session, messages, items) -> frostSigningUIState = FrostSigningUIState.Loaded( localChatRoom = localChatRoom, session = session, + items = items, offeredNonce = messages .filter { it.kind == FrostSigningEvents.NONCE } .map { it.signerPublicKey } @@ -85,12 +88,12 @@ class FrostSigningViewModel( } } - /** The event the group is being asked to sign, for showing it before they agree. */ - fun proposedEvent(session: FrostSigningSession): Event? = - Event.fromJsonOrNull(session.unsignedEventJson) + /** The events the group is being asked to sign, for showing them before they agree. */ + fun proposedEvents(items: List): List = + items.mapNotNull { Event.fromJsonOrNull(it.unsignedEventJson) } - fun signedEvent(session: FrostSigningSession): Event? = - frostSigningRepository.signedEvent(session) + fun signedEvents(items: List): List = + frostSigningRepository.signedEvents(items) fun approve(onDone: () -> Unit) { val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt index ccf8cdcb..515a603f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt @@ -1,6 +1,7 @@ package press.mantra.compose.ui.view.state import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -11,6 +12,12 @@ sealed interface FrostSigningUIState { /** Null on the first emission, before the session has been collected. */ val session: FrostSigningSession? = null, + /** + * The events the session signs, in the order its proposal fixed. Empty + * on the first emission, and for a session whose proposal has not landed. + */ + val items: List = emptyList(), + /** Who has offered a nonce, so the screen can name who it is waiting on. */ val offeredNonce: Set = emptySet(), diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt index 97460090..1f854414 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -20,6 +20,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Instant import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.extensions.toHex @@ -227,20 +228,30 @@ class FrostSigningSessionTest { threshold = 2, participantCount = 3, signerId = signerId, - unsignedEventJson = "{}", - eventId = "e".repeat(64), - nonceRandom = "f".repeat(64), signerIds = signerIds ) + /** The one event such a session signs, signed or not. */ + private fun items(signature: String? = null) = listOf( + FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, + unsignedEventJson = "{}", + eventId = "e".repeat(64), + nonceRandom = "f".repeat(64), + signature = signature + ) + ) + @Test fun `a session waits on its owner until they answer`() { val open = session(signerId = 2, signerIds = null) - assertTrue(FrostSigningManager.isAwaitingApproval(open)) + assertTrue(FrostSigningManager.isAwaitingApproval(open, items())) assertFalse( FrostSigningManager.isAwaitingApproval( - open.copy(signApprovedAt = Instant.fromEpochSeconds(1)) + open.copy(signApprovedAt = Instant.fromEpochSeconds(1)), + items() ) ) } @@ -249,8 +260,12 @@ class FrostSigningSessionTest { fun `a session that has settled asks its owner nothing`() { val open = session(signerId = 2, signerIds = null) - assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE))) - assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED))) + assertFalse( + FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE), items()) + ) + assertFalse( + FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED), items()) + ) } @Test @@ -261,9 +276,10 @@ class FrostSigningSessionTest { // decision in that window offers two bad answers: a nonce nobody is // waiting for, or a refusal that abandons a signature that exists. val signedWithoutThem = session(signerId = 2, signerIds = "0,1") - .copy(signature = "a".repeat(128)) - assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem)) + assertFalse( + FrostSigningManager.isAwaitingApproval(signedWithoutThem, items("a".repeat(128))) + ) } @Test @@ -415,7 +431,7 @@ class FrostSigningCompletionTest { * never named them. Every column the completion path reads is here; the ones * it must not need are deliberately left null. */ - private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession( + private fun leftOutMemberSession() = FrostSigningSession( id = "s".repeat(64), chatRoomId = "room", coordinatorPublicKey = "c".repeat(64), @@ -425,24 +441,30 @@ class FrostSigningCompletionTest { participantCount = 3, signerId = 2, derivationPath = SharedKeyDerivation.formatPath(), + signerIds = null, + signApprovedAt = null + ) + + /** The event that session signs: everything completing it needs, and nothing more. */ + private fun leftOutMemberItem(signature: String? = null) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, unsignedEventJson = unsignedEvent.toJson(), eventId = unsignedEvent.id, nonceRandom = "f".repeat(64), aggregatedNonce = null, - signerIds = null, - signature = signature, - signApprovedAt = null + signature = signature ) @Test fun `a member who never took part can still check what the group signed`() { - val session = leftOutMemberSession(signature) - val signed = FrostSigningManager.signedEvent(session)!! + val item = leftOutMemberItem(signature) + val signed = FrostSigningManager.signedEvent(item)!! assertTrue( Nip01Crypto.verify( signature = signed.sig.hexToByteArray(), - hash = session.eventId.hexToByteArray(), + hash = item.eventId.hexToByteArray(), pubKey = signed.pubKey.hexToByteArray() ), "completing must need only the row: the event, its id and the signature" @@ -454,7 +476,7 @@ class FrostSigningCompletionTest { // Not rebuilt and not rehashed: the id a session is pinned to is the id // the signature is over, so anything that changed here would produce an // event whose signature verifies against nothing. - val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!! + val signed = FrostSigningManager.signedEvent(leftOutMemberItem(signature))!! assertEquals(unsignedEvent.id, signed.id) assertEquals(unsignedEvent.pubKey, signed.pubKey) @@ -466,15 +488,22 @@ class FrostSigningCompletionTest { @Test fun `there is no finished event until the signature arrives`() { - assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession())) + assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberItem())) } @Test fun `the arrived signature is what stops the session asking`() { // The pair that matters to the screen and the transcript: the same row, // before and after the group finished without this member. - assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession())) - assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature))) + assertTrue( + FrostSigningManager.isAwaitingApproval(leftOutMemberSession(), listOf(leftOutMemberItem())) + ) + assertFalse( + FrostSigningManager.isAwaitingApproval( + leftOutMemberSession(), + listOf(leftOutMemberItem(signature)) + ) + ) } @Test @@ -482,7 +511,7 @@ class FrostSigningCompletionTest { // What the check is for. A coordinator passing off something else must not // get it applied and announced as the group's, and the row is all there is // to catch it with. - val other = leftOutMemberSession(signature).copy( + val other = leftOutMemberItem(signature).copy( eventId = EventHasher.hashId( pubKey = groupPubKey, createdAt = 1_700_000_000L, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index ef1e3f52..e8340226 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -19,7 +19,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant -import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.frost.GroupKeyStateEvent @@ -281,21 +281,14 @@ class GroupKeyStateTest { val unsigned = unsignedKeyState(content, tags, createdAt, author) return FrostSigningManager.signedEvent( - session = sessionOver(unsigned), + item = itemOver(unsigned), signature = groupSignature(signer, unsigned.id) ) } - private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( - id = "s".repeat(64), - chatRoomId = chatRoomId, - coordinatorPublicKey = "c00rd1na70r", - userPublicKey = "c00rd1na70r", - dkgSessionId = "ceremony-1", - threshold = threshold, - participantCount = participants, - signerId = 0, - derivationPath = SharedKeyDerivation.formatPath(path), + private fun itemOver(unsignedEvent: Event) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, unsignedEventJson = unsignedEvent.toJson(), eventId = unsignedEvent.id, nonceRandom = "f".repeat(64) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt index a88d7dc9..1e78e49c 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt @@ -170,9 +170,6 @@ class SharedKeyDerivationTest { threshold = 2, participantCount = 3, signerId = 0, - derivationPath = derivationPath, - unsignedEventJson = "{}", - eventId = "e".repeat(64), - nonceRandom = "f".repeat(64) + derivationPath = derivationPath ) } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt index 26bc90f4..6aeea827 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -18,7 +18,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.extensions.toHex @@ -100,24 +100,17 @@ class SignedArtifactTest { sig = "" ) - private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( - id = "s".repeat(64), - chatRoomId = chatRoomId, - coordinatorPublicKey = proposer, - userPublicKey = proposer, - dkgSessionId = "k".repeat(64), - threshold = threshold, - participantCount = participants, - signerId = 0, - derivationPath = SharedKeyDerivation.formatPath(), + private fun itemOver(unsignedEvent: Event) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, unsignedEventJson = unsignedEvent.toJson(), eventId = unsignedEvent.id, nonceRandom = "f".repeat(64) ) - /** A quorum signing the session's event, in the manager's order. */ - private fun groupSignature(session: FrostSigningSession): String { - val message = ByteVector(session.eventId.hexToByteArray()) + /** A quorum signing the item's event, in the manager's order. */ + private fun groupSignature(item: FrostSigningItem): String { + val message = ByteVector(item.eventId.hexToByteArray()) val signerIds = listOf(0, 1) val nonces = signerIds.map { signerId -> @@ -154,8 +147,8 @@ class SignedArtifactTest { /** Everything from the form to the row a device holds afterwards. */ private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent { - val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel))) - val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + val item = itemOver(unsignedEventOf(proposalTemplate(versionLabel))) + val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) return ArtifactEvent( signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig @@ -193,15 +186,15 @@ class SignedArtifactTest { // to be the one that was signed rather than anything recomputed from the // proposer. Otherwise members converge on nothing and each holds its own // copy of what is meant to be one artifact. - val session = sessionOver(unsignedEventOf(proposalTemplate())) - val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + val item = itemOver(unsignedEventOf(proposalTemplate())) + val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) val artifact = MantraArtifact.fromArtifactEvent( ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig), chatRoomId ) - assertEquals(session.eventId, artifact?.id) + assertEquals(item.eventId, artifact?.id) } @Test diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt index 2e03b6f7..d2c8a669 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile @@ -81,6 +82,7 @@ class FrostSigningSessionDaoJvmTest { createdAt: Instant = Instant.fromEpochSeconds(1_000), stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, signature: String? = null, + events: Int = 1, ): FrostSigningSession = FrostSigningSession( id = id, chatRoomId = chatRoomId, @@ -91,12 +93,33 @@ class FrostSigningSessionDaoJvmTest { participantCount = 3, signerId = 1, stage = stage, - unsignedEventJson = "{}", - eventId = "ff".repeat(32), - nonceRandom = "aa".repeat(32), - signature = signature, createdAt = createdAt, - ).also { db.frostSigningSessionDao().upsert(it) } + ).also { + db.frostSigningSessionDao().upsert(it) + db.frostSigningSessionDao().upsertItems( + (0 until events).map { index -> item(id, index, signature) } + ) + } + + /** + * One event of a session's batch. Distinct ids and seeds per index, because + * that is what the manager writes and what every ordering assertion here + * would otherwise be blind to. + */ + private fun item( + sessionId: String, + itemIndex: Int = 0, + signature: String? = null, + aggregatedNonce: String? = null, + ) = FrostSigningItem( + sessionId = sessionId, + itemIndex = itemIndex, + unsignedEventJson = """{"index":$itemIndex}""", + eventId = "f$itemIndex".repeat(32), + nonceRandom = "a$itemIndex".repeat(32), + aggregatedNonce = aggregatedNonce, + signature = signature, + ) private suspend fun message( sessionId: String, @@ -242,6 +265,88 @@ class FrostSigningSessionDaoJvmTest { val found = assertNotNull(db.frostSigningSessionDao().getSessionById("s1")) assertEquals(FrostSigningStage.COMPLETE, found.stage) - assertEquals("ab".repeat(32), found.signature) + assertEquals("ab".repeat(32), db.frostSigningSessionDao().getItems("s1").single().signature) + } + + /** + * A batch's items come back in the order the proposal fixed, whatever order + * they went in. + * + * Not a presentation detail. Nonces and partial signatures are joined + * positionally against this list, so a device reading a batch in a different + * order aggregates item 2's partial signature against item 0's message and + * produces a signature nobody can verify. + */ + @Test + fun `a batch's items read back in index order`() = runBlocking { + seedRooms() + session("s1") + + db.frostSigningSessionDao().upsertItems( + listOf(item("s1", itemIndex = 3), item("s1", itemIndex = 1), item("s1", itemIndex = 2)) + ) + + val items = db.frostSigningSessionDao().getItems("s1") + + assertEquals(listOf(0, 1, 2, 3), items.map { it.itemIndex }) + assertEquals(4, db.frostSigningSessionDao().countItems("s1")) + } + + /** + * A redelivered item overwrites rather than accumulates, which is what the + * composite key buys — the same property the signer messages rely on, and for + * the same reason: a second row would make the batch the wrong length, and + * the length is what every payload is checked against. + */ + @Test + fun `a second write of one item replaces it`() = runBlocking { + seedRooms() + session("s1") + + db.frostSigningSessionDao().upsert(item("s1", signature = "cd".repeat(32))) + + val items = db.frostSigningSessionDao().getItems("s1") + + assertEquals(1, items.size) + assertEquals("cd".repeat(32), items.single().signature) + } + + /** Counted rather than flagged, so "the group has finished" cannot disagree with the rows. */ + @Test + fun `signed items are counted apart from the rest`() = runBlocking { + seedRooms() + session("s1", events = 3) + + assertEquals(3, db.frostSigningSessionDao().countItems("s1")) + assertEquals(0, db.frostSigningSessionDao().countSignedItems("s1")) + + db.frostSigningSessionDao().upsertItems( + listOf(item("s1", 0, signature = "ab".repeat(32)), item("s1", 1, signature = "cd".repeat(32))) + ) + + assertEquals(2, db.frostSigningSessionDao().countSignedItems("s1")) + } + + /** Items belong to their session and go with it, like the signer messages do. */ + @Test + fun `deleting a room takes its sessions' items with it`() = runBlocking { + seedRooms() + session("s1", chatRoomId = roomOne, events = 2) + session("s2", chatRoomId = roomTwo, events = 2) + + db.chatRoomDao().delete(assertNotNull(db.chatRoomDao().findChatRoomById(roomOne)).chatRoom) + + assertEquals(emptyList(), db.frostSigningSessionDao().getItems("s1")) + assertEquals(2, db.frostSigningSessionDao().getItems("s2").size) + } + + /** The single-item read the manager uses to pick one event out of a batch. */ + @Test + fun `an item can be read by its index`() = runBlocking { + seedRooms() + session("s1", events = 3) + + assertEquals("f2".repeat(32), db.frostSigningSessionDao().getItem("s1", 2)?.eventId) + assertNull(db.frostSigningSessionDao().getItem("s1", 3)) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt new file mode 100644 index 00000000..6a2e3ffd --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt @@ -0,0 +1,186 @@ +package press.mantra.compose.database.migrations + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The v9 -> v10 backfill, against a real database holding a real half-finished + * session. + * + * The stake is higher than a migration test usually carries. A session in flight + * at upgrade time holds `nonceRandom`, the seed its secret nonce is regenerated + * from, and `aggregatedNonce`, the aggregate its partial signature is made + * against. If either fails to arrive at item 0, the session's next pass derives a + * *different* nonce for the same message and publishes a second partial signature + * over it -- two partial signatures, one message, two nonces, which is how a + * secret share is extracted. So this asserts the values, not merely that a row + * appeared. + * + * Run against the migration's own SQL on a bare connection rather than through + * Room. That covers the copy and the shape it leaves behind, which is what this + * migration is; it does not cover Room's version wiring, which belongs to + * `PlatformDatabaseBuilder` and is the same for every migration in that list. + */ +class FrostSigningItemMigrationJvmTest { + + private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:") + + @AfterTest + fun close() = connection.close() + + /** v9's `FrostSigningSession`, verbatim from `schemas/9.json`. */ + private fun createV9() { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `FrostSigningSession` (`id` TEXT NOT NULL, " + + "`chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, " + + "`userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, " + + "`threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, " + + "`signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, " + + "`unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, " + + "`nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signerIds` TEXT, " + + "`signature` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, " + + "`approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, " + + "`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), " + + "FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` " + + "ON `FrostSigningSession` (`chatRoomId`)" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` " + + "ON `FrostSigningSession` (`dkgSessionId`)" + ) + } + + /** A session mid-flight: it has its seed, and the aggregate it is signing against. */ + private fun insertV9Session( + id: String = "s1", + aggregatedNonce: String? = null, + signature: String? = null, + ) = connection.execSQL( + "INSERT INTO `FrostSigningSession` VALUES (" + + "'$id', 'room', 'coord', 'user', 'dkg-1', 2, 3, 1, 'm/9420/0/0', 'COLLECTING_NONCES', " + + "'$unsignedEventJson', '$eventId', '$nonceRandom', " + + "${aggregatedNonce.orNull()}, '0,1', ${signature.orNull()}, " + + "NULL, 1, 1, 1000, 1000, 1000)" + ) + + private fun String?.orNull(): String = this?.let { "'$it'" } ?: "NULL" + + /** Column names in declaration order, which is what Room checks the table against. */ + private fun columns(table: String): List = + connection.prepare("PRAGMA table_info(`$table`)").use { statement -> + buildList { while (statement.step()) add(statement.getText(1)) } + } + + /** One column of the one row, as text, or null when the column is null. */ + private fun read(table: String, column: String): String? = + connection.prepare("SELECT `$column` FROM `$table`").use { statement -> + if (statement.step() && !statement.isNull(0)) statement.getText(0) else null + } + + private val unsignedEventJson = """{"kind":1}""" + private val eventId = "ff".repeat(32) + private val nonceRandom = "aa".repeat(32) + private val aggregatedNonce = "bb".repeat(66) + private val signature = "cc".repeat(32) + + @Test + fun `an in-flight session keeps the seed and the aggregate it is already signing against`() = + runBlocking { + createV9() + insertV9Session(aggregatedNonce = aggregatedNonce) + + MIGRATION_9_10.migrate(connection) + + // The two that cannot be regenerated. Losing either turns this + // session's next pass into a second partial signature over one message. + assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom")) + assertEquals(aggregatedNonce, read("FrostSigningItem", "aggregatedNonce")) + + assertEquals("0", read("FrostSigningItem", "itemIndex")) + assertEquals(eventId, read("FrostSigningItem", "eventId")) + assertEquals(unsignedEventJson, read("FrostSigningItem", "unsignedEventJson")) + } + + @Test + fun `a finished session keeps its signature`() = runBlocking { + createV9() + insertV9Session(aggregatedNonce = aggregatedNonce, signature = signature) + + MIGRATION_9_10.migrate(connection) + + assertEquals(signature, read("FrostSigningItem", "signature")) + } + + /** A session that had not been aggregated yet arrives with nothing invented for it. */ + @Test + fun `a session still collecting nonces gets an item with no aggregate`() = runBlocking { + createV9() + insertV9Session() + + MIGRATION_9_10.migrate(connection) + + assertEquals(null, read("FrostSigningItem", "aggregatedNonce")) + assertEquals(null, read("FrostSigningItem", "signature")) + assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom")) + } + + /** + * The shape Room checks on the next open. A column left behind, or one + * missing, is refused at open time -- a crash on launch rather than a bug + * anybody gets to debug. + */ + @Test + fun `the tables are left with exactly the columns v10 declares`() = runBlocking { + createV9() + insertV9Session() + + MIGRATION_9_10.migrate(connection) + + assertEquals( + listOf( + "id", "chatRoomId", "coordinatorPublicKey", "userPublicKey", "dkgSessionId", + "threshold", "participantCount", "signerId", "derivationPath", "stage", + "signerIds", "failureReason", "signApprovedAt", "approvalRequestedAt", + "createdAt", "updatedAt", "savedAt", + ), + columns("FrostSigningSession") + ) + assertEquals( + listOf( + "sessionId", "itemIndex", "unsignedEventJson", "eventId", + "nonceRandom", "aggregatedNonce", "signature", + ), + columns("FrostSigningItem") + ) + } + + /** `signerIds` is shared by every item of a batch, so it stays where it was. */ + @Test + fun `the signer set stays on the session`() = runBlocking { + createV9() + insertV9Session() + + MIGRATION_9_10.migrate(connection) + + assertEquals("0,1", read("FrostSigningSession", "signerIds")) + } + + /** An empty table migrates to an empty table rather than to one bad row. */ + @Test + fun `a database with no sessions gets no items`() = runBlocking { + createV9() + + MIGRATION_9_10.migrate(connection) + + assertEquals(null, read("FrostSigningItem", "sessionId")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index b9a822ad..091ec8b4 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -121,6 +121,13 @@ class SignedGroupKeyStateTest { suspend fun session(sessionId: String): FrostSigningSession? = db.frostSigningSessionDao().getSessionById(sessionId) + /** The events a session signs, in the order its proposal fixed. */ + suspend fun items(sessionId: String) = + db.frostSigningSessionDao().getItems(sessionId) + + /** The one event a session of one signs. */ + suspend fun item(sessionId: String) = items(sessionId).single() + suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId) } @@ -275,7 +282,7 @@ class SignedGroupKeyStateTest { val payload = Event.fromJson(proposal.content) assertEquals(GroupKeyStateEvent.KIND, payload.kind) assertEquals(thresholdPublicKey, payload.content) - assertEquals(session.eventId, payload.id) + assertEquals(creator.item(session.id).eventId, payload.id) } @Test @@ -289,7 +296,7 @@ class SignedGroupKeyStateTest { key = ceremonyOn(creator) ) - val payload = Event.fromJson(session.unsignedEventJson) + val payload = Event.fromJson(creator.item(session.id).unsignedEventJson) // Signing runs at the room's derivation path, so the key the group signs // as is the room's own id. Not the group's root key, which is what an @@ -320,7 +327,7 @@ class SignedGroupKeyStateTest { // Rebuilt from the event's own fields under this device's own reading of // the room. Agreeing on the id is agreeing on every byte that is signed, // the author included. - assertEquals(session.eventId, received.eventId) + assertEquals(creator.item(session.id).eventId, other.item(session.id).eventId) assertEquals(session.derivationPath, received.derivationPath) } @@ -337,7 +344,12 @@ class SignedGroupKeyStateTest { ) pump(creator, other) - assertTrue(FrostSigningManager.isAwaitingApproval(other.session(session.id)!!)) + assertTrue( + FrostSigningManager.isAwaitingApproval( + other.session(session.id)!!, + other.items(session.id) + ) + ) assertTrue( outbox(other).isEmpty(), "a device that has not been asked yet must publish nothing" @@ -394,7 +406,7 @@ class SignedGroupKeyStateTest { FrostSigningManager.approve(other.db, other.room, session.id) pump(creator, other) - val signed = FrostSigningManager.signedEvent(creator.session(session.id)!!) + val signed = FrostSigningManager.signedEvent(creator.item(session.id)) assertNotNull(signed, "a completed session must carry a signed event") assertEquals(adminRoomId, signed.pubKey) @@ -467,7 +479,7 @@ class SignedGroupKeyStateTest { pump(creator, other) listOf(creator, other).forEach { device -> - val dialect = device.db.mantraDialectDao().getDialectById(session.eventId) + val dialect = device.db.mantraDialectDao().getDialectById(device.item(session.id).eventId) assertNotNull(dialect, "a signed dialect should exist on every signer's device") assertEquals("Sepedi", dialect.name) // The ask this whole change serves: the group's work is authored by @@ -601,7 +613,7 @@ class SignedGroupKeyStateTest { ) assertEquals(SharedKeyDerivation.formatPath(sibling), session.derivationPath) - assertEquals(siblingRoomId, Event.fromJson(session.unsignedEventJson).pubKey) + assertEquals(siblingRoomId, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey) pump(creator, other) FrostSigningManager.approve(other.db, other.room, session.id) @@ -640,6 +652,6 @@ class SignedGroupKeyStateTest { assertNull(session.derivationPath, "no path reaches a room that was not derived") assertEquals(emptyList(), session.pathIndices()) - assertEquals(rootPublicKey, Event.fromJson(session.unsignedEventJson).pubKey) + assertEquals(rootPublicKey, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey) } } diff --git a/docs/README.md b/docs/README.md index af213bf5..3ddbf229 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ silent, or a decision that looked arbitrary and was not. |---|---| | [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering | | [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken | +| [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it | | [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite | | [marmot-direct-messages.md](./marmot-direct-messages.md) | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it | | [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app | @@ -19,4 +20,6 @@ Start with the ceremony if you are new to this area; the Marmot notes all assume Read the skipped-keys note before debugging any "the other device never got it" report — it is silent, and it looks like every other kind of delivery failure. The sync note stands alone, and the dead-code inventory reads as a follow-up to it. The +batch-signing note is a plan rather than a description of what is there: read it +after the derivation note, whose one rule is the same one it is built around. The jvm-target note is unrelated to all of them: it is a build and packaging story. diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md new file mode 100644 index 00000000..1002c114 --- /dev/null +++ b/docs/frost-batch-signing.md @@ -0,0 +1,450 @@ +# Batch signing with FROST + +How to have a group sign several events in one ceremony instead of one at a +time, phased, with the cryptographic constraint that shapes every phase stated +first. + +The headline: **there is no such thing as one FROST signature over many +messages, and no way to reuse a nonce across them.** What can be batched is the +ceremony — the rounds, the group events, and the approval a human is asked for. +A batch of `k` events is `k` independent FROST instances run in lockstep, +sharing one signer set, one transcript and one prompt. The saving is transport +and UX, not crypto, and that is the saving worth having: today +[FrostSigningManager](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt) +spends five MLS group events and one human decision per event signed. + +## The constraint + +A Schnorr partial signature is `s = k + e·x`, with `e = H(R‖P‖m)`. Two different +messages under the same nonce `R` give two equations in one unknown and the +secret share falls out. That is not a subtlety to be careful around; it is the +one way a t-of-n key is lost, and it is already what the two write-once rules on +[FrostSigningSession](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt) +exist to prevent. + +So the rule every phase below is built to keep: + +> **Every item in a batch has its own independent nonce, from every signer, and +> that nonce signs exactly one message for the life of the session.** + +`fr.acinq.bitcoin.crypto.frost.Session.create` takes the message and the +aggregated nonce, so a batch is `k` `Session` objects sharing a signer set and a +`TweakCache`. There is no batch primitive in the library and none is needed. + +What *is* shared across items, safely, and what is not: + +| shared across the batch | per item | +|---|---| +| signer set (`signerIds`) | nonce seed (`nonceRandom`) | +| ceremony, threshold, participant count | secret + public nonce | +| derivation path and `TweakCache` | aggregated nonce | +| the human approval | `Session`, partial signature, signature | +| the chat transcript | the unsigned event and its id | + +--- + +## Phase 1 — schema + +**Half a day. No wire change, no behaviour change.** + +Five columns move off `FrostSigningSession` onto a new child table: they are the +per-item ones in the table above. + +```kotlin +@Entity( + primaryKeys = ["sessionId", "itemIndex"], + foreignKeys = [ForeignKey( + entity = FrostSigningSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + )], + indices = [Index("sessionId")], +) +data class FrostSigningItem( + val sessionId: String, + /** Position in the batch. Fixed at proposal; it is the wire ordering. */ + val itemIndex: Int, + val unsignedEventJson: String, + val eventId: HexKey, + val nonceRandom: HexKey, + val aggregatedNonce: HexKey? = null, + val signature: HexKey? = null, +) +``` + +`itemIndex` is load-bearing rather than cosmetic: it is the order every device +joins nonces and partial signatures in, so two devices that disagree about it +produce aggregates nobody can verify. It is fixed by the proposal and never +re-sorted. Spelled `itemIndex` rather than `index` because `index` needs quoting +in every hand-written query it appears in, and one missing backtick is a compile +error at best. + +**No `itemCount` column.** The count is `SELECT COUNT(*) FROM FrostSigningItem +WHERE sessionId = :id`, for the same reason `signerIds` is derived from the +ceremony's participant order rather than stored: a denormalised count is one +more thing that can disagree with the session it describes. + +### Migration 9 → 10 + +Not an auto-migration. Room can add a table but cannot backfill one, and this +migration has to move data before it drops the columns it came from. Manual, in +the shape of `MIGRATION_3_4` — the existing precedent for a migration that is +about data rather than shape. + +```sql +CREATE TABLE FrostSigningItem (...); +INSERT INTO FrostSigningItem (sessionId, itemIndex, unsignedEventJson, eventId, + nonceRandom, aggregatedNonce, signature) + SELECT id, 0, unsignedEventJson, eventId, nonceRandom, aggregatedNonce, signature + FROM FrostSigningSession; +ALTER TABLE FrostSigningSession DROP COLUMN unsignedEventJson; -- and the other four +``` + +**`DROP COLUMN`, not a table rebuild.** The usual SQLite way to remove columns — +create a new table, copy, drop the old one, rename — is unsafe here and quietly +so. `FrostSignerMessage` and the new `FrostSigningItem` both reference +`FrostSigningSession(id)` `ON DELETE CASCADE`, and `DROP TABLE` fires cascades: +with foreign keys enforced it would delete every signer message and every item +the migration had just written. Whether it does depends on Room having turned +foreign keys off around the migration, which is not worth depending on when +`ALTER TABLE ... DROP COLUMN` cannot go wrong. It needs SQLite 3.35 and columns +free of indices and constraints; these five qualify, and `getRoomDatabase` pins +`BundledSQLiteDriver` on every platform, so the SQLite version is ours rather +than the host's. + +**Why the backfill has to be exact.** A session in flight at upgrade time holds +its nonce seed and, possibly, its aggregated nonce in those columns. Losing +either means regenerating a different nonce on the next pass — publishing a +second partial signature over the same message against a different aggregate, +which is the extraction case. Copying them verbatim into item 0 means an +in-flight session resumes as though nothing happened. A migration that dropped +them and let sessions restart would be the one dangerous way to write this. + +Update the entity list and `version = 10` in +[MantraDatabase.kt:175](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt), +regenerate `composeApp/schemas/10.json`, and add the DAO methods: +`getItems(sessionId)`, `observeItems(sessionId)`, `upsertItems(List)`, +`countItems(sessionId)`, `countSignedItems(sessionId)`. + +**Test:** extend +[FrostSigningSessionDaoJvmTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt) +with item CRUD, the `(sessionId, itemIndex)` dedupe, index ordering and the +cascade delete. Add a migration test that runs the migration over a v9 database +holding a half-finished session and asserts the seed and aggregate survive at +index 0 — the values, not merely that a row appeared. + +--- + +## Phase 2 — vectorise the manager at k = 1 + +**Two days. Still no wire change, still no API change.** + +The whole point of this phase is that **every existing test passes unchanged**. +`proposeSigning` still takes one event, still writes one item, and the messages +on the wire are byte-identical to today's. What changes is that `advance()` +loops. + +In [FrostSigningManager.advance](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt): + +1. Load `items` once, ordered by `index`. +2. Nonce generation becomes a `map` over items, each with its own + `session.nonceRandom` → `item.nonceRandom` and its own `message`. +3. `Session.create` becomes one per item; the `signerIds`, `publicShares`, + `nParticipants`, `threshold` and `tweakCache` arguments are the same for all + of them. +4. `sign` and `aggregateSigs` become per item. +5. `complete()` verifies each item's signature against `item.eventId` and calls + `applySignedEvent` for each. + +Three invariants to establish here, because Phase 3 depends on all three: + +- **The signer set and every aggregated nonce are one write-once unit.** The + coordinator writes `session.signerIds` and all `k` `item.aggregatedNonce` + values in a single `@Transaction`, so "some items aggregated" is unreachable. + `signerIds != null` stays the gate the rest of `advance()` reads, exactly as + today. +- **Signatures likewise.** All `k` land in one transaction; `countSignedItems == + countItems` is the settled test, replacing today's `session.signature != null` + in both `advance()` and `isAwaitingApproval`. +- **Item order is the wire order.** Every join and split goes through one pair + of helpers, never an ad-hoc `map` at a call site. + +### The cost that appears here + +`advance()` regenerates nonces and creates FROST sessions **unconditionally on +every inbound message**, before checking whether this device has already +published. At k=1 that is one native call per message and nobody notices. At +k=64 it is 64 nonce generations, 64 `Session.create`s and 64 signs on every +message the group sends — several hundred native calls to discover there is +nothing to do. + +Fix it in this phase, while it is still cheap to verify: short-circuit the +regenerate-and-sign block when this device has already published both its nonce +and its partial-signature messages, and skip `Session.create` for a device that +is not in the signer set and is not the coordinator. Both are pure +optimisations at k=1, which is the point of doing them before k>1 exists. + +**Test:** existing tests are the test. If +[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt) +and +[SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt) +pass without edits beyond the schema move, the vectorisation is faithful. + +--- + +## Phase 3 — k > 1 on the wire + +**Two days. This is the phase with the compatibility trap in it.** + +### Payload encoding + +`FrostSignerMessage` keeps its `(sessionId, signerPublicKey, kind)` primary key +and **one row carries all `k` values**, comma-joined — the same encoding +`FrostSigningSession.signerIds` already uses. + +Per-item message rows were the obvious alternative and are worse. The current +key is what makes a redelivered message overwrite rather than accumulate, which +is what keeps the coordinator's signer set the right length; splitting by item +multiplies the ways a partial delivery can look like a complete one. One group +event carrying a signer's whole contribution also matches the transport: a +signer publishes all `k` nonces or none. + +The parse is strict. A payload whose element count is not the session's item +count is dropped, not truncated and not padded: + +```kotlin +private fun splitPayload(payload: String, expected: Int): List? = + payload.split(",").takeIf { it.size == expected } +``` + +Note *where* this runs. `record()` stores payloads without parsing them, which +is what lets a nonce arrive before its proposal; the count check therefore +belongs in `orderedNonces` and `orderedPartialSignatures`, where the session — +and so the count — is known. Do not move it earlier to "fail faster"; that +breaks the out-of-order replay that `replayStoredMessages` exists for. + +### Proposal encoding, and the compatibility trap + +The proposal's content becomes a JSON **array** of unsigned events — but only +when `k > 1`. + +A single-event session must keep serialising as a bare JSON object, byte for +byte as today. The reason is what an old build does with each form: + +| old build receives | outcome | +|---|---| +| bare object (k=1) | signs it, exactly as now | +| JSON array (k>1) | `Event.fromJsonOrNull` returns null → "does not carry an event; dropping" | + +That is the correct failure. An old device refuses a batch rather than +mis-signing part of one, and a group with mixed versions keeps single signing +working throughout the rollout. Emitting an array unconditionally would break +every k=1 session for old devices and buy nothing. + +`acceptProposal` accepts both: array if the content starts with `[`, otherwise a +one-element list. New devices understand old proposals forever; old devices +understand new single proposals forever. + +### `acceptProposal` over a list + +Each element is rebuilt from its own fields under the room's path and checked +against the id it claims — the existing check, per item, and the whole proposal +is dropped if any one fails. The write-once rule extends from "the event this +session signs" to "the ordered list of events this session signs": a second +proposal under the same id whose list differs anywhere is logged and ignored, +never applied. + +### A cap on `k` + +Enforce a maximum batch size — 64 is a sane starting number — in +`proposeSigning` **and independently in `acceptProposal`**. + +The second check is the one that matters. Without it a proposer can hand every +member a batch of arbitrary size and have them do unbounded native work and +publish an unbounded group event, from a single message. The proposal is the one +place in this protocol where a remote party decides how much work everyone else +does, and it is currently bounded only by never having more than one item. + +Size it against the MLS group event limit rather than picking a round number: +each signer's nonce message is `k × 133` bytes of hex-and-commas, the partial +message `k × 65`, and the proposal itself carries `k` whole events, which is the +term that actually binds. + +--- + +## Phase 4 — the batch API and its call sites + +**A day.** + +```kotlin +suspend fun proposeSigningBatch( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List, // kind, tags, content + key: DkgSession? = null, + createdAt: Long = Clock.System.now().epochSeconds +): FrostSigningSession +``` + +`proposeSigning` stays, as the one-element call into it, so +[AddDialectViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt), +[AddArtifactViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt) +and +[GroupKeyStateManager.propose](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt) +need no edit at all. `GroupKeyStateManager` in particular should never batch: a +room's key state is the statement every other session is opened against, and +bundling it with anything else would make it as available as its worst +co-passenger. + +Mirror both on +[FrostSigningRepository](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt), +including the `NO_OP` implementation. `signedEvent(session): Event?` becomes +`signedEvents(session): List`. + +### Nonce seeds + +`k` independent 32-byte seeds, generated at proposal, one per item. Deriving +them from a single session seed by index would work and save nothing worth +having: independent seeds mean an off-by-one in index handling produces a +session that fails to aggregate, rather than one that signs two messages under +one nonce. + +### Failure policy: all or nothing + +A batch fails whole. If any item's aggregation fails, `fail()` runs as it does +today, nothing is applied, and the group is told once. + +This is a real cost and should be stated where callers can see it: **a batch is +only as available as its worst item**, so bundling unrelated events makes both +less likely to get signed. Batch things that belong together. + +The retry rule is the one that must not be got wrong, and deserves a comment on +`proposeSigningBatch` itself: **a retry is a new session id with new seeds.** +Never re-propose a failed batch under its own id, and never reuse an item's +`nonceRandom`. This is true of single sessions today; batches make it more +tempting to get wrong, because a batch that failed on item 5 looks like it has +four perfectly good nonces going spare. It does not — those four have already +been published against an aggregate. + +Per-item partial success is deliberately out of scope. It would need mixed-state +UI, a transcript that can say "3 of 5", and a `complete()` that applies a subset, +which is a lot of surface for an outcome that indicates a bug or a dishonest +coordinator rather than a normal ending. + +--- + +## Phase 5 — UI + +**Two days, most of it in the screen.** + +- `FrostSigningUIState.Loaded` gains `items: List`. +- [FrostSigningViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt) + combines `observeItems` into its existing `combine`; nothing else changes, + since it already re-renders on every session write. +- [FrostSigningScreen](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt) + renders a list where it renders one event today. + +**The approval gate must show every event, not a count.** The argument in +`FrostSigningManager`'s own header — one approval rather than three, because the +event is fixed before the member is asked — only holds if the member can see +what they are approving. "Sign 12 events?" behind a chevron is a worse prompt +than the twelve prompts it replaces. A member who cannot scroll the whole list +should not be able to approve it. + +`FrostSigningRoute` is unchanged: it already carries a session id, and a batch is +one session. + +### Transcript + +No new `ChatMessage` types. The existing `TYPE_FROST_*` constants, +`FROST_REQUEST_FULFILMENTS`, `FROST_SETTLEMENTS` and `FROST_TYPES` in +[ChatMessage.kt:220](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt) +all work as they stand — one line per step, per member, whatever `k` is. Only +the wording in `announceStep` and `announceStarted` gains a count: "signed their +part of 12 events". This is worth checking rather than assuming, because a new +type would need adding to five sets and would silently render as a chat bubble +if missed from `FROST_TYPES`. + +--- + +## Phase 6 — the test that actually proves it + +**A day, and do not skip it.** + +Extend +[SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt), +which already runs a real signing session between two devices over two +databases, with a `k = 3` batch: both devices reach `COMPLETE`, all three +signatures verify against their own event ids under the room's key, and all +three events are applied locally on both. + +Then in +[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt), +against real FROST and no database: + +- a `k = 3` batch producing three signatures nostr accepts, from one signer set; +- **the negative one that matters** — assert the three aggregated nonces are + pairwise distinct, and that the three seeds are. It is the cheapest possible + guard against the one mistake in this document that loses the key, and it will + catch an index bug that every positive test still passes; +- a wrong-length payload is dropped rather than truncated; +- a second proposal under the same session id with a changed item is ignored. + +--- + +## Phase 7 — rollout + +**No code.** + +Nothing here needs a feature flag. `k = 1` is the entire behaviour of the app +after Phase 4, byte-identical on the wire to the app before Phase 1, and no +caller batches anything until one is written to. Phases 1 and 2 are shippable on +their own and worth shipping on their own — they are a schema move and a +refactor, and landing them apart from the wire change means a bisect over a +signing bug lands on one or the other rather than on all of it. + +Before the first caller batches, confirm the group is on a build that +understands array proposals. There is no negotiation for this and adding one is +not worth it; the failure mode is a batch that never reaches threshold and is +abandoned, which is visible in the transcript and costs nothing but a retry. + +--- + +## Appendix — what was considered and rejected + +**One signature over `k` messages.** Does not exist for Schnorr. Aggregate +signature schemes that do this (BLS) are a different curve and a different +verification story, and nostr verifies BIP-340. + +**One nonce, `k` messages.** This is the extraction attack, described at the top. + +**Nonce pre-processing** — the FROST paper's own batching, where signers +pre-publish a list of π nonce commitments before any message is known, and each +later signature consumes one commitment per signer in a single online round. The +library supports it: `SecretNonce.generate` takes `message` as nullable. + +Rejected for now, and it is worth writing down why, because it is the thing +somebody will suggest next. It cuts *latency* rather than message count, which +is not the complaint. And it costs the property this whole design rests on: +today a nonce seed is safe to store and regenerate from **because** a session +signs one fixed message and cannot be made to sign another. Banked nonces have +no message to be bound to at generation time, so their safety moves from a +structural argument to a used/unused ledger that must be right across crashes, +redeliveries and two devices. A bug there leaks a share. Revisit only if +round-trip latency becomes the actual problem. + +**`Frost.deterministicSign` (BIP-445)**, where a signer going last derives its +nonce from the other signers' aggregate and persists nothing, is orthogonal to +batching but relevant to the same file. It would let one signer per session hold +no nonce state at all. Not part of this work. + +**Signing one manifest event that commits to `k` items** — a list of ids, or a +Merkle root. One signature, no protocol change, and by far the cheapest thing on +this page. Rejected as the general answer because each item stops carrying its +own verifiable signature, and the whole point of +[shared-key-derivation.md](./shared-key-derivation.md) is that a reader holding +one signed dialect can check it against the room it was found in without a +lookup. Still the right answer for any case where the items are only ever read +together. From 935a8fe37ad204a63d85262fb5a66cef949bb0e5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:40:22 +0200 Subject: [PATCH 05/10] refactor(frost): run a signing session as k FROST instances in lockstep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of docs/frost-batch-signing.md. Pure refactor: proposals still carry one event, the wire is byte-identical, and every test passes unchanged -- 344 jvmTest and 217 testDebugUnitTest, none of them edited in this commit. advance() now loops over FrostSigningItem rows rather than reading the first one. One nonce per item, one aggregate per item, one Session.create per item, one partial signature per item, one signature per item. The signer set, the public shares, the tweak cache and the approval stay shared, because they are the terms that do not enter e = H(R‖P‖m). The coordinator's aggregation is the place where that distinction bites: it builds one AggregatedNonce per item, each from that item's nonce from each chosen signer. Reusing one across two items would be reusing R across two messages. ## The payload codec, early joinPayload/splitPayload land here rather than with the wire change, because at a batch of one a comma join is the identity -- the payload is the bare value it has always been. That leaves Phase 3 to the proposal encoding alone. splitPayload is strict: a payload that is not exactly the batch's length is dropped rather than truncated or padded. It runs in orderedNonces, orderedPartialSignatures and splitForSession -- never in record(), which stores payloads without parsing them so that a nonce can arrive before the proposal that would give it a length to check against. ## Two short-circuits, and one trap in the first advance() runs on every arriving message, so at a batch of k it was k native key generations, k Session.creates and k signs each time, usually to discover there was nothing left to do. - Nonces are generated by `lazy`. The obvious version -- a guard computing `ownNonce == null || (isSigner() && ownPartial == null)` -- is wrong, and wrong in a way that reads fine and fails every signing test: the coordinator settles the signer set further down the same pass, so isSigner() at the top is false on exactly the pass where the coordinator goes on to sign, and the nonces are never generated. Reproduced as IndexOutOfBounds before switching to lazy, which has no prediction to make. - A device that is neither signing nor aggregating leaves before building any FROST session, rather than building k of them to do nothing with. Co-Authored-By: Claude Opus 5 --- .../compose/managers/FrostSigningManager.kt | 249 +++++++++++++----- docs/frost-batch-signing.md | 27 +- 2 files changed, 206 insertions(+), 70 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 240d56bf..8444f461 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -65,6 +65,20 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * The path comes from the room, never from a proposal -- [signingPath] -- because * it decides which key the group signs as. * + * ### It signs a batch + * + * A session carries one or more events as [FrostSigningItem] rows, and runs one + * FROST instance per event in lockstep: one signer set, one aggregate per item, + * one partial signature per item per signer, one approval. Four group events + * whatever the size, instead of four per event. + * + * That is all the batching there is, and all there can be. A Schnorr partial + * signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under one + * nonce give two equations in one unknown and the secret share falls out; a + * batch shares only what is outside that equation. Every item has its own seed, + * its own aggregate and its own `Session`, and [joinPayload] is the only place + * they travel together. + * * Three things are genuinely different from a ceremony, and each of them is why * this is a separate manager rather than another branch of that one. * @@ -80,8 +94,8 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * * `SecretNonce` cannot be serialised and refuses to be used twice. Storing the * randomness it is derived from and regenerating on demand is the only way a - * signing session can survive the app closing -- and it is safe only because a - * session signs one message and cannot be made to sign another. See + * signing session can survive the app closing -- and it is safe only because an + * item signs one message and cannot be made to sign another. See * [FrostSigningSession] for the two rules that hold that in place; both are * enforced here, in [acceptProposal] and in [record]. * @@ -89,8 +103,10 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * * A DKG asks three times because each step publishes something different and * commits the member to something different. Here every step serves one - * decision -- sign this event or do not -- and the event is fixed before the - * member is asked. A second prompt would be the same question again. + * decision -- sign these events or do not -- and they are fixed before the + * member is asked. A second prompt would be the same question again. That holds + * for a batch only as long as the member can see every event in it before + * answering, which is the screen's side of the bargain. */ object FrostSigningManager { private const val TAG = "FrostSigningManager" @@ -414,7 +430,10 @@ object FrostSigningManager { // holds all of them. if (current(database, session).signerIds != null) return true - if (applyAggregate(database, session, signerIds, listOf(innerEvent.content))) { + val aggregated = splitForSession(database, session, innerEvent.content) + ?: return true + + if (applyAggregate(database, session, signerIds, aggregated)) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -427,7 +446,10 @@ object FrostSigningManager { // could neither finish nor safely retry. if (isSigned(database, session)) return true - if (applySignatures(database, session, listOf(innerEvent.content))) { + val signatures = splitForSession(database, session, innerEvent.content) + ?: return true + + if (applySignatures(database, session, signatures)) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -515,42 +537,63 @@ object FrostSigningManager { // The events this session signs, in the order the proposal fixed. Every // join below is positional against it. val items = database.frostSigningSessionDao().getItems(sessionId) - val item = items.firstOrNull() ?: return - val message = ByteVector(item.eventId.hexToByteArray()) + if (items.isEmpty()) return - // Regenerated rather than stored -- SecretNonce refuses both. Safe - // because an item's message can never change; see the notes on - // FrostSigningSession. - val (secretNonce, publicNonce) = SecretNonce.generate( - sessionRandom = ByteVector32(item.nonceRandom), - secretShare = secretShare, - publicShare = publicShares?.getOrNull(session.signerId), - tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, - message = message, - extraInput = null - ) + val messages = items.map { ByteVector(it.eventId.hexToByteArray()) } - if (ownMessage(database, session, FrostSigningEvents.NONCE) == null) { + val ownNonces = ownMessage(database, session, FrostSigningEvents.NONCE) + val ownPartials = ownMessage(database, session, FrostSigningEvents.PARTIAL_SIGNATURE) + + // One nonce per item, regenerated rather than stored -- SecretNonce + // refuses both. Safe because an item's message can never change; see + // the notes on FrostSigningSession. + // + // On demand rather than up front, and that is the point of the `lazy`: + // `advance` runs on every arriving message, so at a batch of k this is + // k native key generations each time, usually to find there is nothing + // left to publish. A guard computed here instead would have to predict + // whether this device turns out to be a signer, which it cannot -- the + // coordinator settles the signer set further down this same pass. + val nonces by lazy { + items.mapIndexed { index, item -> + SecretNonce.generate( + sessionRandom = ByteVector32(item.nonceRandom), + secretShare = secretShare, + publicShare = publicShares?.getOrNull(session.signerId), + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = messages[index], + extraInput = null + ) + } + } + + if (ownNonces == null) { publishOwn( database, localChatRoom, session, FrostSigningEvents.NONCE, - publicNonce.data.toHex() + joinPayload(nonces.map { (_, publicNonce) -> publicNonce.data.toHex() }) ) } if (session.isCoordinator() && session.signerIds == null) { - val offered = orderedNonces(database, session) ?: return + val offered = orderedNonces(database, session, items.size) ?: return val chosen = offered.take(session.threshold) - - val aggregated = IndividualNonce.aggregate(chosen.map { it.second }) - .orThrow("aggregating nonces") - val chosenIds = chosen.map { (id, _) -> id } - if (!applyAggregate(database, session, chosenIds, listOf(aggregated.toByteArray().toHex()))) { - return + + // One aggregate per item, each built from that item's nonce from + // each chosen signer. Reusing one across two items would be reusing + // R across two messages, which is the whole thing this design is + // arranged to make impossible. + val aggregated = items.indices.map { index -> + IndividualNonce.aggregate(chosen.map { (_, offeredNonces) -> offeredNonces[index] }) + .orThrow("aggregating nonces") + .toByteArray() + .toHex() } + + if (!applyAggregate(database, session, chosenIds, aggregated)) return session = current(database, session) broadcast( @@ -558,7 +601,7 @@ object FrostSigningManager { localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.SIGNER_SET, - content = aggregated.toByteArray().toHex(), + content = joinPayload(aggregated), signerIds = chosenIds ) announceStep( @@ -570,48 +613,70 @@ object FrostSigningManager { } val signerIds = session.signerIdList() ?: return - val aggregatedNonce = database.frostSigningSessionDao() - .getItem(sessionId, item.itemIndex)?.aggregatedNonce ?: return + // Re-read, because the aggregate above is written to the item rows. + val aggregated = database.frostSigningSessionDao().getItems(sessionId) session = moveTo(database, session, FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES) - val signingSession = Session.create( - aggregatedNonce = AggregatedNonce(aggregatedNonce.hexToByteArray()), - signerIds = signerIds.map { it.toUInt() }, - signerPublicShares = publicShares?.let { shares -> - signerIds.mapNotNull { shares.getOrNull(it) }.takeIf { it.size == signerIds.size } - }, - nParticipants = session.participantCount, - threshold = session.threshold, - tweakCache = tweakCache, - message = message - ) - // A member outside the chosen set has nothing to contribute and is not // holding anybody up. They stay in the session to receive the finished - // signature like everybody else. - if (session.isSigner() && ownMessage(database, session, FrostSigningEvents.PARTIAL_SIGNATURE) == null) { - val partialSignature = signingSession - .sign(secretNonce, secretShare, session.signerId.toUInt()) - .orThrow("signing") + // signatures like everybody else -- and leaving here rather than + // building k FROST sessions to do nothing with is the same saving the + // nonce short-circuit above makes. + val signing = session.isSigner() && ownPartials == null + val aggregating = session.isCoordinator() && !isSigned(database, session) + if (!signing && !aggregating) { + complete(database, session) + return + } + + // One FROST session per item. `Session.create` binds the message and + // the aggregate together, so only the signer set, the shares and the + // tweak cache are shared across a batch. + val signingSessions = aggregated.mapIndexed { index, item -> + Session.create( + aggregatedNonce = AggregatedNonce( + (item.aggregatedNonce ?: return).hexToByteArray() + ), + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = publicShares?.let { shares -> + signerIds.mapNotNull { shares.getOrNull(it) }.takeIf { it.size == signerIds.size } + }, + nParticipants = session.participantCount, + threshold = session.threshold, + tweakCache = tweakCache, + message = messages[index] + ) + } + + if (signing) { + val partialSignatures = signingSessions.mapIndexed { index, signingSession -> + signingSession + .sign(nonces[index].first, secretShare, session.signerId.toUInt()) + .orThrow("signing") + .toHex() + } publishOwn( database, localChatRoom, session, FrostSigningEvents.PARTIAL_SIGNATURE, - partialSignature.toHex() + joinPayload(partialSignatures) ) } - if (session.isCoordinator() && !isSigned(database, session)) { - val partials = orderedPartialSignatures(database, session, signerIds) ?: return + if (aggregating) { + val partials = orderedPartialSignatures(database, session, signerIds, aggregated.size) + ?: return - val signature = signingSession - .aggregateSigs(partials.map { ByteVector32(it) }) - .orThrow("aggregating partial signatures") - .toHex() + val signatures = signingSessions.mapIndexed { index, signingSession -> + signingSession + .aggregateSigs(partials.map { ByteVector32(it[index]) }) + .orThrow("aggregating partial signatures") + .toHex() + } - if (!applySignatures(database, session, listOf(signature))) return + if (!applySignatures(database, session, signatures)) return session = current(database, session) broadcast( @@ -619,7 +684,7 @@ object FrostSigningManager { localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.SIGNATURE, - content = signature + content = joinPayload(signatures) ) announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey) } @@ -872,17 +937,25 @@ object FrostSigningManager { */ private suspend fun orderedNonces( database: MantraDatabase, - session: FrostSigningSession - ): List>? { + session: FrostSigningSession, + items: Int + ): List>>? { val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null val idByMember = signerIds(database, key) val offered = database.frostSigningSessionDao() .getMessagesByKind(session.id, FrostSigningEvents.NONCE) .mapNotNull { message -> - idByMember[message.signerPublicKey]?.let { id -> - id to IndividualNonce(message.payload.hexToByteArray()) + val id = idByMember[message.signerPublicKey] ?: return@mapNotNull null + val values = splitPayload(message.payload, items) ?: run { + logger.w( + "Session ${session.id}: ${message.signerPublicKey.take(8)} offered a " + + "nonce payload that is not $items value(s); leaving them out" + ) + return@mapNotNull null } + + id to values.map { IndividualNonce(it.hexToByteArray()) } } .sortedBy { (id, _) -> id } @@ -902,8 +975,9 @@ object FrostSigningManager { private suspend fun orderedPartialSignatures( database: MantraDatabase, session: FrostSigningSession, - signerIds: List - ): List? { + signerIds: List, + items: Int + ): List>? { val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null val memberById = signerIds(database, key).entries.associate { (member, id) -> id to member } @@ -912,7 +986,9 @@ object FrostSigningManager { .associate { it.signerPublicKey to it.payload } val partials = signerIds.mapNotNull { id -> - memberById[id]?.let { payloadByMember[it] } + val payload = memberById[id]?.let { payloadByMember[it] } ?: return@mapNotNull null + + splitPayload(payload, items)?.map { it.hexToByteArray() } } if (partials.size < signerIds.size) { @@ -920,7 +996,50 @@ object FrostSigningManager { return null } - return partials.map { it.hexToByteArray() } + return partials + } + + /** + * A signer's whole contribution to a batch, as one payload. + * + * Comma separated, the encoding [FrostSigningSession.signerIds] already uses, + * and at a batch of one it is the bare value — which is what keeps a + * single-event session on exactly the wire it has always been on. + * + * One row per signer per kind rather than one per item, deliberately. + * [FrostSignerMessage]'s key is what makes a redelivered message replace its + * predecessor instead of adding a row, and splitting by item would multiply + * the ways a partial delivery can look like a complete one. + */ + private fun joinPayload(values: List): String = values.joinToString(",") + + /** + * The other half, and strict: a payload not carrying exactly [expected] + * values is refused rather than truncated or padded. + * + * Checked here rather than in [record] on purpose. Payloads are stored + * without being parsed, which is what lets a nonce arrive before the proposal + * that would give it a length to be checked against. This runs where the + * session — and so the length — is known. + */ + private fun splitPayload(payload: String, expected: Int): List? = + payload.split(",").map { it.trim() }.takeIf { it.size == expected } + + /** [splitPayload] against the number of events this session signs. */ + private suspend fun splitForSession( + database: MantraDatabase, + session: FrostSigningSession, + payload: String + ): List? { + val expected = database.frostSigningSessionDao().countItems(session.id) + + return splitPayload(payload, expected) ?: run { + logger.w( + "Session ${session.id}: payload carries ${payload.split(",").size} value(s) " + + "for $expected item(s); ignoring" + ) + null + } } /** diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md index 1002c114..1a4a250b 100644 --- a/docs/frost-batch-signing.md +++ b/docs/frost-batch-signing.md @@ -179,11 +179,25 @@ k=64 it is 64 nonce generations, 64 `Session.create`s and 64 signs on every message the group sends — several hundred native calls to discover there is nothing to do. -Fix it in this phase, while it is still cheap to verify: short-circuit the -regenerate-and-sign block when this device has already published both its nonce -and its partial-signature messages, and skip `Session.create` for a device that -is not in the signer set and is not the coordinator. Both are pure -optimisations at k=1, which is the point of doing them before k>1 exists. +Fix it in this phase, while it is still cheap to verify: generate nonces on +demand, and leave before building any `Session` when this device is neither +signing nor aggregating. Both are pure optimisations at k=1, which is the point +of doing them before k>1 exists. + +**Generate them lazily, not behind a guard.** The obvious version — compute the +nonces only when `ownNonce == null || (isSigner() && ownPartial == null)` — is +wrong, and wrong in a way that passes a reading and fails every test. The +coordinator settles the signer set *further down the same pass*, so `isSigner()` +read at the top of `advance()` is false on the pass where the coordinator is +about to become a signer, and the nonces it then needs were never generated. A +`by lazy` has no such prediction to make: it generates at first use, at most +once per pass, and never on a pass with nothing to publish. + +### The payload codec, early + +`joinPayload`/`splitPayload` land here rather than in Phase 3, because at k=1 +they are the identity — a one-element comma join is the bare value — so they +change no byte on the wire and leave Phase 3 to the proposal encoding alone. **Test:** existing tests are the test. If [FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt) @@ -199,6 +213,9 @@ pass without edits beyond the schema move, the vectorisation is faithful. ### Payload encoding +*(Landed in Phase 2 — see above. Restated here because the rest of this phase +depends on it.)* + `FrostSignerMessage` keeps its `(sessionId, signerPublicKey, kind)` primary key and **one row carries all `k` values**, comma-joined — the same encoding `FrostSigningSession.signerIds` already uses. From 59c34263b3f3ff90d79598a6aaba111912fb0463 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:48:24 +0200 Subject: [PATCH 06/10] feat(frost): let one signing session carry a batch of events Phase 3 of docs/frost-batch-signing.md. A session can now be proposed over several events, and the whole batch is signed in one round of four group events with one approval. 356 jvmTest and 224 testDebugUnitTest pass. ## The wire, and the compatibility rule that shapes it FrostSigningEvents.encodeProposal serialises a batch of one as the bare event object it always was, and only a genuine batch as a JSON array. That is not tidiness. A build predating this reads an array with Event.fromJsonOrNull, gets null, and drops the proposal -- so an old device refuses a batch outright rather than signing part of one, while single signing keeps working right through a mixed-version rollout. Emitting an array unconditionally would break every one-event session for those devices and buy nothing. decodeProposal accepts both forms permanently: proposals in the old shape do not stop arriving because this build stopped writing them. It is all-or-nothing -- an array with one unreadable element is refused rather than silently shortened, because the batch's length is what every later payload is checked against, and a proposal that quietly lost an event would have every signer's contribution rejected for being the wrong size: a stall with nothing to blame. ## MAX_BATCH_SIZE, checked twice 64, enforced in proposeSigningBatch and again, independently, in acceptProposal. The second check is the one that matters. A proposal is the only place in this protocol where a remote party decides how much work everyone else does -- k native key generations, k signatures, and a group event carrying k payloads, from a single message -- and until batching that was bounded only by never being more than one. ## acceptProposal over a list Each element is rebuilt from its own fields under this device's own reading of the room's path and checked against the id it claims, exactly as before but per item, and the whole proposal is dropped if any one fails. The write-once rule widens from "the event this session signs" to "the ordered list of events this session signs": a second proposal under the same id whose list differs anywhere is logged and ignored. ## The API FrostSigningManager.proposeSigningBatch(events: List>) is public here rather than in Phase 4, because without it there is no way to produce a k>1 session and everything above would ship untested. proposeSigning keeps its signature as the one-event form, so no caller moves. Each template carries its own createdAt. ## Tests - FrostProposalCodecTest (new, commonTest): a batch of one is byte-for-byte the old JSON object -- the assertion that stands in for the old build nobody can run here -- plus order preservation, old-form decoding, and refusal of empty, malformed and partly-unreadable arrays. - SignedGroupKeyStateTest: a k=3 batch between two devices over two databases. Three signatures verifying against the room, three dialects applied on both devices in order, five messages from the coordinator and two from the other signer, and one approval line rather than three. - The negative test that matters: no two items of a batch share an aggregated nonce or a seed, and the two devices' seeds do not intersect. Every positive test still passes if two items share a nonce -- the signatures verify fine; what sharing costs is the secret share. - The cap is refused when proposed. Co-Authored-By: Claude Opus 5 --- .../compose/managers/FrostSigningManager.kt | 142 ++++++++++--- .../compose/nostr/frost/FrostSigningEvents.kt | 49 ++++- .../nostr/frost/FrostProposalCodecTest.kt | 98 +++++++++ .../managers/SignedGroupKeyStateTest.kt | 197 ++++++++++++++++++ docs/frost-batch-signing.md | 51 +++-- 5 files changed, 496 insertions(+), 41 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 8444f461..f44508a2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -7,6 +7,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.utils.RandomInstance import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 @@ -113,6 +114,23 @@ object FrostSigningManager { private val logger = Logger.withTag(TAG) + /** + * The most events one session will sign. + * + * Enforced when proposing, and again -- independently -- when a proposal + * arrives. The second check is the one that matters. A proposal is the only + * place in this protocol where a remote party decides how much work everybody + * else does: k native key generations, k signatures, and a group event + * carrying k payloads, all from a single message. Until batching that was + * bounded by never being more than one. + * + * Sized against what a group event will carry rather than picked round. A + * signer's nonce message is k * 133 bytes of hex and commas and their partial + * message k * 65, neither of which binds; the proposal carries k whole + * events, which does. + */ + const val MAX_BATCH_SIZE: Int = 64 + /** * Opens a signing session, making this device the coordinator. * @@ -140,7 +158,46 @@ object FrostSigningManager { content: String, key: DkgSession? = null, createdAt: Long = Clock.System.now().epochSeconds + ): FrostSigningSession = proposeSigningBatch( + database = database, + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + events = listOf(EventTemplate(createdAt, kind, tags, content)), + key = key + ) + + /** + * Opens a session over several events at once, making this device the + * coordinator. + * + * One ceremony, one signer set, one approval and four group events, whatever + * the size -- but k independent FROST instances underneath, because that is + * the only thing a batch can be. See [FrostSigningItem] for why. + * + * A batch is all-or-nothing: if any item cannot be aggregated the session + * fails and none of its events are applied. That makes a batch **only as + * available as its worst item**, so events that do not belong together + * should not be proposed together. + * + * A failed batch is retried by proposing a *new* one, never by re-proposing + * this session. Its items' nonce seeds have already been published against an + * aggregate; reusing any of them for a second attempt would produce two + * partial signatures over one secret nonce, which is how a share is + * extracted. [itemsOver] mints fresh seeds precisely so that a retry is a new + * session by construction. + */ + suspend fun proposeSigningBatch( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List>, + key: DkgSession? = null ): FrostSigningSession { + require(events.isNotEmpty()) { "A signing session must be given something to sign" } + require(events.size <= MAX_BATCH_SIZE) { + "A signing session will sign at most $MAX_BATCH_SIZE events, not ${events.size}" + } + val ceremony = key?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null } ?: completedKey(database, localChatRoom.chatRoom.id) ?: throw IllegalStateException("This group has no shared key to sign with") @@ -149,7 +206,16 @@ object FrostSigningManager { ?: throw IllegalStateException("This device is not a participant in ceremony ${ceremony.id}") val path = signingPath(database, localChatRoom, ceremony) - val unsignedEvent = unsignedEventOf(ceremony, path, kind, tags, content, createdAt) + val unsignedEvents = events.map { template -> + unsignedEventOf( + key = ceremony, + path = path, + kind = template.kind, + tags = template.tags, + content = template.content, + createdAt = template.createdAt + ) + } val sessionId = RandomInstance.bytes(32).toHex() val session = FrostSigningSession( @@ -166,17 +232,20 @@ object FrostSigningManager { signApprovedAt = Clock.System.now() ) database.frostSigningSessionDao().upsert(session) - database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent))) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, unsignedEvents)) announceStarted(database, session) - logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${ceremony.id}") + logger.i( + "Proposing signature $sessionId over ${describe(unsignedEvents.size)} " + + "(${unsignedEvents.joinToString { it.id.take(8) }}) with key ${ceremony.id}" + ) broadcast( database = database, localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.PROPOSAL, - content = unsignedEvent.toJson(), + content = FrostSigningEvents.encodeProposal(unsignedEvents), includeKey = true ) @@ -253,12 +322,13 @@ object FrostSigningManager { // under the same id carrying different ones is either a mistake or an // attempt to get two signatures out of one secret nonce, which is how // a share is extracted -- so it is refused, not applied. - val proposed = Event.fromJsonOrNull(innerEvent.content) + val proposed = FrostSigningEvents.decodeProposal(innerEvent.content) val signing = database.frostSigningSessionDao().getItems(sessionId).map { it.eventId } - if (proposed != null && signing != listOf(proposed.id)) { + if (proposed != null && signing != proposed.map { it.id }) { logger.w( - "Session $sessionId re-proposed with event ${proposed.id}, " + - "but it is already signing ${signing.joinToString()}; ignoring" + "Session $sessionId re-proposed with ${describe(proposed.size)} " + + "(${proposed.joinToString { it.id.take(8) }}), but it is already signing " + + "${signing.joinToString { it.take(8) }}; ignoring" ) } return existing @@ -282,9 +352,19 @@ object FrostSigningManager { return null } - val proposed = Event.fromJsonOrNull(innerEvent.content) + val proposed = FrostSigningEvents.decodeProposal(innerEvent.content) if (proposed == null) { - logger.w("Signing proposal $sessionId does not carry an event; dropping") + logger.w("Signing proposal $sessionId does not carry any events; dropping") + return null + } + + // Checked here as well as when proposing, because this is where a remote + // party gets to decide how much work this device does. See [MAX_BATCH_SIZE]. + if (proposed.size > MAX_BATCH_SIZE) { + logger.w( + "Signing proposal $sessionId asks for ${proposed.size} events, " + + "more than the $MAX_BATCH_SIZE this device will sign at once; dropping" + ) return null } @@ -298,20 +378,29 @@ object FrostSigningManager { // could choose the key the group signs as, and every signer would put // their share behind an author none of them checked. val path = signingPath(database, localChatRoom, key) - val unsignedEvent = unsignedEventOf( - key = key, - path = path, - kind = proposed.kind, - tags = proposed.tags, - content = proposed.content, - createdAt = proposed.createdAt - ) - if (unsignedEvent.id != proposed.id) { - logger.w( - "Signing proposal $sessionId carries id ${proposed.id} but its fields hash " + - "to ${unsignedEvent.id}; dropping" + val unsignedEvents = proposed.map { event -> + unsignedEventOf( + key = key, + path = path, + kind = event.kind, + tags = event.tags, + content = event.content, + createdAt = event.createdAt ) - return null + } + + // All of them or none. A batch's length is what every later payload is + // checked against, so quietly dropping one bad event would leave a session + // that rejects every signer's contribution for being the wrong size -- + // a stall with nothing to blame it on. + unsignedEvents.forEachIndexed { index, rebuilt -> + if (rebuilt.id != proposed[index].id) { + logger.w( + "Signing proposal $sessionId carries id ${proposed[index].id} at $index " + + "but its fields hash to ${rebuilt.id}; dropping" + ) + return null + } } val session = FrostSigningSession( @@ -329,10 +418,13 @@ object FrostSigningManager { derivationPath = path?.let(SharedKeyDerivation::formatPath) ) database.frostSigningSessionDao().upsert(session) - database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent))) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, unsignedEvents)) announceStarted(database, session) - logger.i("Recorded signing session $sessionId over ${unsignedEvent.id}; awaiting approval") + logger.i( + "Recorded signing session $sessionId over ${describe(unsignedEvents.size)} " + + "(${unsignedEvents.joinToString { it.id.take(8) }}); awaiting approval" + ) announceApprovalNeeded(database, session) replayStoredMessages(database, session) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt index 04b99d1d..c26c79f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -1,6 +1,9 @@ package press.mantra.compose.nostr.frost +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.serialization.json.jsonArray +import press.mantra.compose.network.serialization.CommonJson import press.mantra.compose.nostr.frost.tags.FrostKeyTag import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag @@ -47,8 +50,9 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag */ object FrostSigningEvents { /** - * Opens a session. Content is the unsigned nostr event, as JSON; the key to - * sign with is named in [FrostKeyTag]. + * Opens a session. Content is the unsigned nostr event -- or, for a batch, + * the array of them -- as JSON; the key to sign with is named in [FrostKeyTag]. + * See [encodeProposal] for why the two forms are not one. */ val PROPOSAL: Kind = 30320 @@ -104,4 +108,45 @@ object FrostSigningEvents { fun parseSignerIds(tags: Array>): List? = tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds + + /** + * The unsigned events of a proposal, as its content. + * + * A batch of one serialises as the bare event object it always did, and only + * a genuine batch becomes an array. That is not tidiness. A build predating + * batching reads an array with `Event.fromJsonOrNull`, gets null, and drops + * the proposal -- so an old device refuses a batch outright rather than + * signing part of one, while single signing keeps working right through a + * mixed-version rollout. Emitting an array unconditionally would break every + * one-event session for those devices and buy nothing. + * + * An empty list has no honest encoding and is not one, so callers do not + * produce it and [decodeProposal] does not accept it. + */ + fun encodeProposal(events: List): String = + events.singleOrNull()?.toJson() + ?: events.joinToString(separator = ",", prefix = "[", postfix = "]") { it.toJson() } + + /** + * The other half. Both forms are accepted, permanently: proposals in the old + * shape do not stop arriving just because this build stopped writing them. + * + * All-or-nothing. An array with one unreadable element is refused rather than + * silently shortened, because the batch's length is what every later payload + * is checked against -- a proposal that quietly lost an event would have every + * signer's contribution rejected for being the wrong size, which is a stall + * with no message to blame. + */ + fun decodeProposal(content: String): List? { + if (!content.trimStart().startsWith("[")) { + return Event.fromJsonOrNull(content)?.let { listOf(it) } + } + + val elements = runCatching { CommonJson.parseToJsonElement(content).jsonArray } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + ?: return null + + return elements.map { element -> Event.fromJsonOrNull(element.toString()) ?: return null } + } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt new file mode 100644 index 00000000..f63474ad --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt @@ -0,0 +1,98 @@ +package press.mantra.compose.nostr.frost + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * How a proposal's events go on the wire, and what an old build makes of them. + * + * The whole reason this encoding has two shapes rather than one is + * compatibility, and compatibility is exactly the thing no other test in this + * area can see: a build that predates batching is not here to be run against. + * So the property is asserted from the other side -- a batch of one is byte for + * byte the JSON object it always was, which is the only form such a build can + * read. + */ +class FrostProposalCodecTest { + private fun event(content: String, kind: Int = 1) = Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000L, + kind = kind, + tags = arrayOf(arrayOf("alt", "test")), + content = content, + sig = "" + ) + + @Test + fun `a batch of one is the bare event, exactly as it always was`() { + val one = event("just this") + + // The load-bearing assertion. A device on a build that predates batching + // parses this with Event.fromJsonOrNull; anything but the bare object + // returns null there and the proposal is dropped, so every single-event + // session in a mixed-version group would stop working. + assertEquals(one.toJson(), FrostSigningEvents.encodeProposal(listOf(one))) + } + + @Test + fun `a real batch is an array`() { + val encoded = FrostSigningEvents.encodeProposal( + listOf(event("first"), event("second")) + ) + + assertTrue(encoded.startsWith("["), "a batch must be a JSON array") + assertTrue(encoded.endsWith("]")) + } + + @Test + fun `what is encoded is what comes back, in order`() { + val events = listOf(event("first"), event("second"), event("third")) + + val decoded = FrostSigningEvents.decodeProposal( + FrostSigningEvents.encodeProposal(events) + ) + + // Order is the batch's index, and every join of nonces and partial + // signatures is positional against it. + assertEquals( + events.map { it.content }, + decoded?.map { it.content } + ) + } + + @Test + fun `a bare event decodes as a batch of one, forever`() { + // Proposals in the old shape do not stop arriving just because this build + // stopped writing them. + val decoded = FrostSigningEvents.decodeProposal(event("from an older build").toJson()) + + assertEquals(1, decoded?.size) + assertEquals("from an older build", decoded?.single()?.content) + } + + @Test + fun `an array with one unreadable element is refused whole`() { + // Not shortened. The batch's length is what every later payload is checked + // against, so a proposal that quietly lost an event would have every + // signer's contribution rejected for being the wrong size. + val encoded = """[${event("fine").toJson()},{"not":"an event"}]""" + + assertNull(FrostSigningEvents.decodeProposal(encoded)) + } + + @Test + fun `an empty array is not a proposal`() { + assertNull(FrostSigningEvents.decodeProposal("[]")) + } + + @Test + fun `nonsense is not a proposal`() { + assertNull(FrostSigningEvents.decodeProposal("[this is not json")) + assertNull(FrostSigningEvents.decodeProposal("")) + assertNull(FrostSigningEvents.decodeProposal("null")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 091ec8b4..0217cb68 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -14,11 +14,13 @@ import fr.acinq.bitcoin.crypto.frost.KeyMaterial import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgParticipantMessage @@ -458,6 +460,201 @@ class SignedGroupKeyStateTest { assertEquals(adminRoomId, absent.keyState()?.announcedBy) } + // ---- Signing several events in one session ----------------------------- + + /** + * The batch, end to end, between two devices over two databases: three + * events, one signer set, one approval, and three signatures that verify + * against the room. + * + * Everything about batching that could be wrong and still compile is wrong + * here or nowhere -- a shared nonce, a mismatched order, a payload split the + * wrong way. A signature that verifies is the only evidence any of it is + * wired up correctly, and there are three of them to disagree. + */ + @Test + fun `a batch of three is signed in one session`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val items = device.items(session.id) + + assertEquals(3, items.size, "every device signs the whole batch") + assertEquals(FrostSigningStage.COMPLETE, device.session(session.id)?.stage) + + items.forEach { item -> + assertTrue( + Nip01Crypto.verify( + signature = assertNotNull(item.signature).hexToByteArray(), + hash = item.eventId.hexToByteArray(), + pubKey = adminRoomId.hexToByteArray() + ), + "item ${item.itemIndex} must verify against the room it was signed in" + ) + } + } + } + + /** + * The one that catches the mistake this whole design exists to prevent. + * + * Every positive test above still passes if two items share a nonce -- the + * signatures verify perfectly well. What sharing costs is the secret share, + * to anyone who sees both partial signatures. So the aggregates and the seeds + * are asserted pairwise distinct, which is cheap and is the only assertion + * here that an index bug cannot slip past. + */ + @Test + fun `no two events in a batch share a nonce`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val items = device.items(session.id) + + assertEquals(3, items.mapNotNull { it.aggregatedNonce }.toSet().size) + assertEquals(3, items.map { it.nonceRandom }.toSet().size) + assertEquals(3, items.map { it.eventId }.toSet().size) + } + + // A seed is this device's own, so the two devices must not have arrived + // at the same ones either. + assertEquals( + emptySet(), + creator.items(session.id).map { it.nonceRandom }.toSet() + .intersect(other.items(session.id).map { it.nonceRandom }.toSet()) + ) + } + + /** Four messages for three events, which is the whole point of batching. */ + @Test + fun `a batch costs one round of messages, not one per event`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + // Proposal, nonce, signer set, partial signature, signature. + assertEquals( + listOf( + FrostSigningEvents.PROPOSAL, + FrostSigningEvents.NONCE, + FrostSigningEvents.SIGNER_SET, + FrostSigningEvents.PARTIAL_SIGNATURE, + FrostSigningEvents.SIGNATURE, + ), + outbox(creator).map { it.kind } + ) + + // The other member is a signer and not the coordinator: one nonce and one + // partial signature, each carrying all three values. + assertEquals( + listOf(FrostSigningEvents.NONCE, FrostSigningEvents.PARTIAL_SIGNATURE), + outbox(other).map { it.kind } + ) + outbox(other).forEach { queued -> + assertEquals(3, queued.content.split(",").size, "kind ${queued.kind} must carry three values") + } + + // And one question put to the member, not three. + assertEquals( + 1, + other.db.chatMessageDao().getChatMessagesByChatRoomId(other.roomId) + .count { it.chatMessage.messageType == ChatMessage.TYPE_FROST_APPROVAL_NEEDED } + ) + } + + /** Every event of a batch is applied, not just the first. */ + @Test + fun `every dialect in a batch lands on every device`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val names = device.items(session.id).map { item -> + assertNotNull( + device.db.mantraDialectDao().getDialectById(item.eventId), + "item ${item.itemIndex} should have been applied" + ).name + } + + assertEquals(listOf("Sepedi", "isiZulu", "Setswana"), names) + } + } + + /** + * A proposal is the one place a remote party decides how much work everybody + * else does, so the size cap is checked on arrival and not only when + * proposing. + */ + @Test + fun `a batch larger than the cap is refused when proposed`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + + val tooMany = (0..FrostSigningManager.MAX_BATCH_SIZE).map { index -> + DialectEvent.build(name = "d$index", country = "ZA", language = "l$index") + } + + assertFailsWith { + FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = tooMany + ) + } + + Unit + } + + /** Three dialects, distinct enough that an order bug shows up as a wrong name. */ + private fun dialects() = listOf( + Triple("Sepedi", "ZA", "nso"), + Triple("isiZulu", "ZA", "zul"), + Triple("Setswana", "ZA", "tsn"), + ).map { (name, country, language) -> + DialectEvent.build(name = name, country = country, language = language) + } + // ---- What a room signs as, once it has a key state --------------------- @Test diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md index 1a4a250b..7bce1d6e 100644 --- a/docs/frost-batch-signing.md +++ b/docs/frost-batch-signing.md @@ -286,7 +286,20 @@ does, and it is currently bounded only by never having more than one item. Size it against the MLS group event limit rather than picking a round number: each signer's nonce message is `k × 133` bytes of hex-and-commas, the partial message `k × 65`, and the proposal itself carries `k` whole events, which is the -term that actually binds. +term that actually binds. 64 to start. + +### `proposeSigningBatch` on the manager, here + +The public API on the manager lands in this phase rather than the next one, for +a plain reason: there is no other way to produce a `k > 1` session, so without it +everything above ships untested. `proposeSigning` becomes its one-event form and +keeps its signature, so no caller moves. Phase 4 is then the repository, the call +sites and the failure policy. + +That also makes this the phase where the batch is proved end to end — a `k = 3` +session between two devices over two databases, plus the negative test that no +two items share a nonce. Both are described under Phase 6 and are worth reading +there; they simply run here, because this is where the thing they test exists. --- @@ -294,14 +307,16 @@ term that actually binds. **A day.** +*(`FrostSigningManager.proposeSigningBatch` itself landed in Phase 3 — see +above. This phase is what surrounds it.)* + ```kotlin suspend fun proposeSigningBatch( database: MantraDatabase, localChatRoom: LocalChatRoom, userPublicKey: HexKey, - events: List, // kind, tags, content + events: List>, // each carries its own createdAt key: DkgSession? = null, - createdAt: Long = Clock.System.now().epochSeconds ): FrostSigningSession ``` @@ -390,24 +405,32 @@ if missed from `FROST_TYPES`. **A day, and do not skip it.** +*(The first two ran in Phase 3, where the batch first existed. Kept here because +this is the section anybody adding to these tests will read.)* + Extend [SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt), which already runs a real signing session between two devices over two databases, with a `k = 3` batch: both devices reach `COMPLETE`, all three -signatures verify against their own event ids under the room's key, and all -three events are applied locally on both. +signatures verify against their own event ids under the room's key, all three +events are applied locally on both, and the whole thing costs five messages from +the coordinator and two from the other signer rather than one round per event. -Then in -[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt), -against real FROST and no database: +**The negative one that matters**: assert the three aggregated nonces are +pairwise distinct, and that the three seeds are. Every positive test above still +passes if two items share a nonce — the signatures verify perfectly well; what +sharing costs is the secret share, to anyone who sees both partial signatures. +It is the cheapest possible guard against the one mistake in this document that +loses the key, and it catches an index bug nothing else here would. + +Then, still to write: -- a `k = 3` batch producing three signatures nostr accepts, from one signer set; -- **the negative one that matters** — assert the three aggregated nonces are - pairwise distinct, and that the three seeds are. It is the cheapest possible - guard against the one mistake in this document that loses the key, and it will - catch an index bug that every positive test still passes; - a wrong-length payload is dropped rather than truncated; -- a second proposal under the same session id with a changed item is ignored. +- a second proposal under the same session id with a changed item is ignored; +- a `k = 3` batch in + [FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt), + against real FROST and no database, for the same reason that file exists at + all: the library calls are checked without a database in the way. --- From 426e53be9d86c54b5bf3898ae1ec9c0a0a06eb34 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:50:35 +0200 Subject: [PATCH 07/10] feat(frost): offer batch signing through the repository Phase 4 of docs/frost-batch-signing.md: the app-facing surface for what Phase 3 built, plus the two rules a caller has to know before reaching for it. FrostSigningRepository.proposeSigningBatch takes a List> and returns the one session that signs all of them. proposeSigning stays exactly as it was -- AddDialectViewModel, AddArtifactViewModel and GroupKeyStateManager need no edit, and none is made here. Both forms now share one `proposing` helper for the throw-to-null conversion. The reason it exists is unchanged and now covers two more cases: proposing throws when the group has no key, when this device was not in the ceremony, and now when a batch is empty or over MAX_BATCH_SIZE. All four are states the UI is supposed to have checked for, so they become a null the caller reports. ## The two rules, written where a caller will read them A batch is only as available as its worst item. It is all-or-nothing, so if any event cannot be aggregated the session fails and none of them are applied -- which means events that do not belong together should not travel together. A retry is a new batch, never the same one again. A failed batch looks like it has perfectly good nonces going spare; it does not. Every item's seed has already been published against an aggregate, and reusing one would produce two partial signatures over a single secret nonce. proposeSigningBatch mints fresh seeds, so proposing afresh is safe by construction and re-proposing is the only way to get it wrong. GroupKeyStateManager.propose records that it must never be batched: it is the statement every other session in the room is opened against, so bundling it with a dialect would make the room's ability to sign at all depend on that dialect's aggregation succeeding. Per-item partial success stays out of scope -- it would need mixed-state UI, a transcript that can say "3 of 5", and a complete() that applies a subset, for an outcome that indicates a bug or a dishonest coordinator rather than a normal ending. 356 jvmTest and 224 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 --- .../DatabaseFrostSigningRepository.kt | 32 ++++++++++++++--- .../compose/managers/GroupKeyStateManager.kt | 6 ++++ .../repository/FrostSigningRepository.kt | 34 +++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt index 5a7b00b2..cc93b0f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import press.mantra.compose.database.MantraDatabase @@ -56,7 +57,7 @@ class DatabaseFrostSigningRepository( kind: Kind, tags: Array>, content: String - ): FrostSigningSession? = try { + ): FrostSigningSession? = proposing(localChatRoom) { FrostSigningManager.proposeSigning( database = database, localChatRoom = localChatRoom, @@ -65,10 +66,33 @@ class DatabaseFrostSigningRepository( tags = tags, content = content ) + } + + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List> + ): FrostSigningSession? = proposing(localChatRoom) { + FrostSigningManager.proposeSigningBatch( + database = database, + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + events = events + ) + } + + /** + * Proposing throws when the group has no key, when this device was not in the + * ceremony, or when a batch is empty or over the cap. All four are states the + * UI is supposed to have checked for, so they become a null the caller + * reports rather than a crash. + */ + private inline fun proposing( + localChatRoom: LocalChatRoom, + propose: () -> FrostSigningSession + ): FrostSigningSession? = try { + propose() } catch (e: Throwable) { - // Proposing throws when the group has no key or this device was not in the - // ceremony. Both are states the UI is supposed to have checked for, so this - // is a null the caller reports rather than a crash. logger.e("Error proposing a signature in ${localChatRoom.chatRoom.id}", e) null } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index 750197b1..e1e094d9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -72,6 +72,12 @@ object GroupKeyStateManager { * [key] is handed to the signing session rather than looked up from the * room, because the room has no key state yet and looking one up is exactly * what this session exists to make possible. + * + * A session of one, always, and never batched with anything else. A batch is + * all-or-nothing, so it is only as available as its worst item -- and this is + * the statement every other session in the room is opened against. Bundling + * it with a dialect would make the room's ability to sign at all depend on + * that dialect's aggregation succeeding. */ suspend fun propose( database: MantraDatabase, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt index 66abf28e..82ce1e3c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -3,6 +3,7 @@ package press.mantra.compose.repository import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import press.mantra.compose.database.model.FrostSignerMessage @@ -64,6 +65,33 @@ interface FrostSigningRepository { content: String ): FrostSigningSession? + /** + * Opens one session over several events, so a group answers once instead of + * once per event. + * + * Four group events and one approval whatever the size, but k independent + * FROST instances underneath -- there is no such thing as one signature over + * k messages, and no way to share a nonce between two of them. See + * [FrostSigningItem]. + * + * Two things a caller has to decide before reaching for this. + * + * **A batch is only as available as its worst item.** It is all-or-nothing: + * if any event cannot be aggregated the session fails and none of them are + * applied. Events that do not belong together should not travel together. + * + * **A retry is a new batch, never this one again.** Its items' nonce seeds + * have already been published against an aggregate, and reusing one would + * produce two partial signatures over a single secret nonce -- which is how + * a share is extracted. Propose afresh; the manager mints new seeds by + * construction. + */ + suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List> + ): FrostSigningSession? + /** Agrees to sign, letting the session publish this device's part and run on. */ suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) @@ -102,6 +130,12 @@ interface FrostSigningRepository { content: String ): FrostSigningSession? = null + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List> + ): FrostSigningSession? = null + override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit From 9ac4bcbee39b93777c35c55d2f9bc1fe04fba52f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:54:12 +0200 Subject: [PATCH 08/10] feat(frost): show a whole batch on the signing screen, and say so in the chat Phase 5 of docs/frost-batch-signing.md. The screen renders every event of a batch, and the transcript says how many there are. ## The approval gate The argument for one approval rather than one per event -- in FrostSigningManager's own header -- only holds if the member can see everything they are agreeing to. Two gates enforce that, and neither touches "Don't sign". - Every event has to be readable. `readable` compares the events that rendered against the items the session holds, so a batch with one unreadable element offers no Sign button at all rather than a Sign button for the ones that worked. A batch is all-or-nothing: agreeing to the two that rendered would be agreeing to the third as well. - A batch's Sign button waits until the list has been read to the end. A batch can hide an event below the fold in a way one event cannot -- what is off-screen is not further detail about the thing on screen, it is a different thing the member would also be signing. Only for k>1: a single event's screen behaves exactly as it did. Declining stays enabled through both. A member who cannot check what they are being asked to sign should still be able to say no, and saying nothing is indistinguishable from a phone in a pocket, which leaves the group waiting. ## Rendering WhatIsBeingSigned takes the list and the count it expects. Each event is still described as the thing it is -- a dialect, an artifact, a chapter -- by the extracted OneThingBeingSigned; the header counts them and the closing sentence about the group's key is said once for the batch rather than once per event. ## Transcript No new ChatMessage types, and no edits to FROST_TYPES, FROST_SETTLEMENTS or FROST_REQUEST_FULFILMENTS -- one line per member per step still describes what happened, whatever k is. Only the wording gains the number, because each of those lines describes work that covered the whole batch: "signed their part of all 3 events", "combined the parts into the group's 3 signatures", "asked the group to sign 3 events". At k=1 every line is byte-identical to before. 356 jvmTest and 224 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 --- .../compose/managers/FrostSigningManager.kt | 36 ++++++-- .../ui/composable/FrostSigningScreen.kt | 91 ++++++++++++++++--- 2 files changed, 103 insertions(+), 24 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index f44508a2..28521d5e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -1486,15 +1486,20 @@ object FrostSigningManager { ) } - private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) = + private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) { + val count = database.frostSigningSessionDao().countItems(session.id) + announce( database = database, session = session, messageType = ChatMessage.TYPE_FROST_STARTED, - content = "asked the group to sign something with its shared key. It takes " + - "${session.threshold} of ${session.participantCount} members to do it.", + content = "asked the group to sign " + + (if (count > 1) "$count events" else "something") + + " with its shared key. It takes ${session.threshold} of " + + "${session.participantCount} members to do it.", actor = session.coordinatorPublicKey ) + } /** * Tells the group's chat that this session is waiting on the reader. @@ -1511,12 +1516,15 @@ object FrostSigningManager { update(database, session) { it.copy(approvalRequestedAt = Clock.System.now()) } + val count = database.frostSigningSessionDao().countItems(session.id) + announce( database = database, session = session, messageType = ChatMessage.TYPE_FROST_APPROVAL_NEEDED, - content = "Your approval is needed to sign with the group's shared key. Nothing " + - "has been published from this device yet.", + content = "Your approval is needed to sign " + + (if (count > 1) "$count events " else "") + + "with the group's shared key. Nothing has been published from this device yet.", actor = session.userPublicKey ) } @@ -1535,19 +1543,29 @@ object FrostSigningManager { kind: Kind, actor: HexKey ) { + // A batch is still one line per member per step -- what changes is the + // number in it. Every one of these lines describes work that covered the + // whole batch, so saying so is the difference between a member reading + // "signed their part" and knowing what they signed. + val count = database.frostSigningSessionDao().countItems(session.id) + val batch = count > 1 + val (messageType, content) = when (kind) { FrostSigningEvents.NONCE -> ChatMessage.TYPE_FROST_NONCE to - "offered to help sign, sending the one-time value their signature needs." + "offered to help sign, sending the one-time " + + (if (batch) "values their signatures need." else "value their signature needs.") FrostSigningEvents.SIGNER_SET -> ChatMessage.TYPE_FROST_SIGNER_SET to "chose who is signing and combined their one-time values." FrostSigningEvents.PARTIAL_SIGNATURE -> ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE to - "signed their part. On its own it proves nothing; combined with the " + - "others it is the group's signature." + "signed their part" + (if (batch) " of all $count events" else "") + + ". On its own it proves nothing; combined with the others it is " + + "the group's " + (if (batch) "signatures." else "signature.") FrostSigningEvents.SIGNATURE -> ChatMessage.TYPE_FROST_SIGNATURE to - "combined the parts into the group's signature." + "combined the parts into the group's " + + (if (batch) "$count signatures." else "signature.") // PROPOSAL and FAILURE are announced by the code that acts on them -- // both say more than the message itself carries. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 2480394e..ce1b62a2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -138,15 +138,25 @@ fun FrostSigningScreen( // flash an error on the way in. val session = state.session ?: return@Scaffold Loading(padding) + val proposed = frostSigningViewModel.proposedEvents(state.items) + + // Every event of the batch has to be readable before any of it can + // be signed. A member cannot check what they cannot see, and a + // batch is all-or-nothing: agreeing to the two that rendered would + // be agreeing to the third as well. + val readable = state.items.isNotEmpty() && proposed.size == state.items.size + + val scrollState = rememberScrollState() + Column( modifier = Modifier .padding(padding) .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp) ) { - WhatIsBeingSigned(frostSigningViewModel.proposedEvents(state.items).firstOrNull()) + WhatIsBeingSigned(proposed, state.items.size) HorizontalDivider() @@ -234,17 +244,38 @@ fun FrostSigningScreen( Text( text = "Nothing has been published from this device yet. Signing " + - "puts your share behind this event; it cannot be taken back.", + "puts your share behind " + + (if (proposed.size > 1) "all ${proposed.size} of these events" else "this event") + + "; it cannot be taken back.", style = MaterialTheme.typography.bodySmall ) + // A batch can hide an event below the fold in a way one + // event cannot: what is off-screen is not further detail + // about the thing on screen, it is a different thing the + // member would also be signing. So a batch's Sign button + // waits until the list has been read to the end. maxValue + // is Int.MAX_VALUE until the first layout, and 0 when + // everything already fits. + val seenEverything = proposed.size <= 1 || + (scrollState.maxValue != Int.MAX_VALUE && scrollState.value >= scrollState.maxValue) + + if (readable && !seenEverything) { + Text( + text = "Read to the end of the list to sign.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically ) { Button( - enabled = !frostSigningViewModel.isActionPending.value, + enabled = readable && seenEverything && + !frostSigningViewModel.isActionPending.value, onClick = { frostSigningViewModel.approve(onNavigateBack) } ) { Icon(Icons.Default.Draw, contentDescription = null) @@ -252,6 +283,11 @@ fun FrostSigningScreen( Text("Sign") } + // Declining stays available whatever the screen could + // not show. A member who cannot check what they are + // being asked to sign should still be able to say no, + // and saying nothing is indistinguishable from a phone + // in a pocket -- which leaves the group waiting. TextButton( colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error @@ -289,16 +325,48 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { * refusing to describe an event is better than describing it wrongly. */ @Composable -private fun WhatIsBeingSigned(event: Event?) { - if (event == null) { +private fun WhatIsBeingSigned(events: List, expected: Int) { + if (expected == 0 || events.size != expected) { Text( - text = "This session's event could not be read, so there is nothing to check " + - "before signing. Don't sign it.", + text = if (expected <= 1) { + "This session's event could not be read, so there is nothing to check " + + "before signing. Don't sign it." + } else { + "Only ${events.size} of this session's $expected events could be read, so " + + "there is no way to check what you would be signing. Don't sign it." + }, color = MaterialTheme.colorScheme.error ) return } + Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { + if (events.size > 1) { + Text( + text = "${events.size} events, signed together", + style = MaterialTheme.typography.labelMedium + ) + } + + events.forEachIndexed { index, event -> + if (index > 0) HorizontalDivider() + + OneThingBeingSigned(event) + } + + Text( + text = "Signed by the group, not by you. Once enough members sign, " + + (if (events.size > 1) "these are" else "this is") + + " published under the group's shared key.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** One event of the batch, described as the thing it is. */ +@Composable +private fun OneThingBeingSigned(event: Event) { val (label, detail) = when (event.kind) { DialectEvent.KIND -> "New dialect" to DialectEvent( event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig @@ -337,13 +405,6 @@ private fun WhatIsBeingSigned(event: Event?) { Text(text = label, style = MaterialTheme.typography.labelMedium) Text(text = detail, style = MaterialTheme.typography.titleMedium) - - Text( - text = "Signed by the group, not by you. Once enough members sign, this is " + - "published under the group's shared key.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } From 2309879153c4a692d95622ef60df60e20c07b4da Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:58:48 +0200 Subject: [PATCH 09/10] test(frost): cover the batch's failure modes and its crypto without a database Phase 6 of docs/frost-batch-signing.md. 361 jvmTest and 227 testDebugUnitTest pass. ## Inbound path (SignedGroupKeyStateTest) Both drive the manager with a hand-built inner event rather than one the other device queued, which is the only way to be a faulty or dishonest member in this harness. - A one-value nonce offered for a three-item batch does not count towards the threshold: the coordinator never reaches a signer set. The length check is all that stands between a batch and a signer whose contribution lines up against the wrong messages, so truncating or padding would produce partial signatures aggregated against events nobody agreed to. The test then pumps the real nonce and the batch completes -- it is a stall, not damage, which is FrostSignerMessage's composite key doing its job. - A second proposal under the session's own id changes neither its event ids nor its seeds. Every seed is already committed to its item's message; a different batch under the same id would have those seeds produce a second partial signature over a second message, which is how a share is extracted. ## Real FROST, no database (FrostSigningRoundTest) - A k=3 batch from one signer set, all three verifying against the room's key -- the manager's shape with the database taken out of the way. - Item 0's signature does not verify against item 1. Signing three events in lockstep must not make any of them interchangeable. - Both halves of the no-shared-nonce property, because either alone is enough to be relied on by accident: SecretNonce.generate mixes the message in, so one seed under two messages already gives two nonces -- and the manager mints distinct seeds regardless. Co-Authored-By: Claude Opus 5 --- .../compose/managers/FrostSigningRoundTest.kt | 99 ++++++++++++++++ .../managers/SignedGroupKeyStateTest.kt | 112 ++++++++++++++++++ docs/frost-batch-signing.md | 28 +++-- 3 files changed, 232 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt index 1f854414..5a34e8aa 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -160,6 +160,105 @@ class FrostSigningRoundTest { ) } + @Test + fun `one signer set signs a batch of three, and every signature verifies`() { + // The manager's batch, with the database taken out of the way: one signer + // set and one tweak cache shared, and a nonce, a Session and a signature + // per event. If any of that is wired up wrongly the aggregate simply + // fails to verify, which is the whole reason this file exists. + val ids = listOf("first", "second", "third").map(::eventId) + val messages = ids.map { ByteVector(it.hexToByteArray()) } + + val signerIds = listOf(0, 1) + + // A seed per signer per item. The manager mints these independently; here + // they only have to differ, which is the property under test. + val nonces = signerIds.map { signerId -> + messages.mapIndexed { index, message -> + nonceOf(signerId, message, "f".repeat(62) + "$index${signerId + 1}") + } + } + + val signatures = messages.mapIndexed { index, message -> + val session = sessionFor(signerIds, nonces.map { it[index].second }, message) + + val partials = signerIds.mapIndexed { position, signerId -> + session.sign( + nonces[position][index].first, + keyMaterial.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + session.aggregateSigs(partials).right!! + } + + ids.forEachIndexed { index, id -> + assertTrue( + Nip01Crypto.verify( + signature = signatures[index].toByteArray(), + hash = id.hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ), + "item $index of the batch must verify against the room's own key" + ) + } + } + + @Test + fun `a batch signature does not carry to another item of the same batch`() { + // What keeps a batch k independent signatures rather than one loose one. + // Signing three events in lockstep must not make any of them + // interchangeable. + val ids = listOf("first", "second").map(::eventId) + val messages = ids.map { ByteVector(it.hexToByteArray()) } + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + messages.mapIndexed { index, message -> + nonceOf(signerId, message, "9".repeat(62) + "$index${signerId + 1}") + } + } + + val session = sessionFor(signerIds, nonces.map { it[0].second }, messages[0]) + val partials = signerIds.mapIndexed { position, signerId -> + session.sign( + nonces[position][0].first, + keyMaterial.secretShares[signerId], + signerId.toUInt() + ).right!! + } + val first = session.aggregateSigs(partials).right!! + + assertFalse( + Nip01Crypto.verify( + signature = first.toByteArray(), + hash = ids[1].hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ), + "item 0's signature must not verify against item 1" + ) + } + + @Test + fun `two items of a batch never share a nonce`() { + // The one mistake in this whole design that loses the key, asserted at the + // level where it would be made. `SecretNonce.generate` mixes the message + // in, so two items of a batch cannot collide even given the same seed -- + // but the manager gives them distinct seeds as well, and both halves are + // checked here because either alone is enough to be relied on by accident. + val messages = listOf("first", "second").map { ByteVector(eventId(it).hexToByteArray()) } + + val sameSeed = messages.map { nonceOf(0, it, "7".repeat(64)).second.data.toHex() } + assertEquals(2, sameSeed.toSet().size, "one seed under two messages must give two nonces") + + val distinctSeeds = messages.mapIndexed { index, message -> + nonceOf(0, message, "8".repeat(63) + "$index").second.data.toHex() + } + assertEquals(2, distinctSeeds.toSet().size) + assertEquals(emptySet(), sameSeed.toSet().intersect(distinctSeeds.toSet())) + } + @Test fun `a signature over one event does not verify against another`() { val id = eventId("the group agrees") diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 0217cb68..9a3dfc45 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -646,6 +646,118 @@ class SignedGroupKeyStateTest { Unit } + /** + * A payload that is not the batch's length is left out, not truncated -- and + * leaving it out stalls the session rather than poisoning it. + * + * The length check is the only thing standing between a batch and a signer + * whose contribution lines up against the wrong messages. Truncating a long + * payload or padding a short one would produce partial signatures aggregated + * against events nobody agreed to. + */ + @Test + fun `a nonce payload of the wrong length is left out rather than truncated`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + + // One nonce offered for a batch of three, from a member the coordinator + // would otherwise have picked. + FrostSigningManager.processSigningPayload( + database = creator.db, + localChatRoom = creator.room, + innerEvent = Event( + id = "1".repeat(64), + pubKey = other.publicKey, + createdAt = 1, + kind = FrostSigningEvents.NONCE, + tags = FrostSigningEvents.assembleTags(session.id), + content = "aa".repeat(66), + sig = "" + ), + userPublicKey = creator.publicKey + ) + + assertNull( + creator.session(session.id)?.signerIds, + "a payload of the wrong length must not count towards the threshold" + ) + + // And it is a stall, not damage: the real nonce replaces it and the batch + // finishes. That is the composite key on FrostSignerMessage doing its job. + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + assertEquals(FrostSigningStage.COMPLETE, creator.session(session.id)?.stage) + assertEquals(3, creator.items(session.id).count { it.signature != null }) + } + + /** + * A second proposal under a session's own id cannot change what it signs. + * + * The rule the whole design rests on. Every item's nonce seed is already + * committed to that item's message; giving the session a different batch + * would have those seeds produce a second partial signature over a second + * message, which is how a secret share is extracted. + */ + @Test + fun `a second proposal under the same id cannot change what a session signs`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + + val before = other.items(session.id) + + val substitute = DialectEvent.build(name = "Xitsonga", country = "ZA", language = "tso") + FrostSigningManager.processSigningPayload( + database = other.db, + localChatRoom = other.room, + innerEvent = Event( + id = "2".repeat(64), + pubKey = creator.publicKey, + createdAt = 1, + kind = FrostSigningEvents.PROPOSAL, + tags = FrostSigningEvents.assembleTags(session.id, dkgSessionId = ceremonyId), + content = FrostSigningEvents.encodeProposal( + listOf( + Event( + id = "3".repeat(64), + pubKey = adminRoomId, + createdAt = substitute.createdAt, + kind = substitute.kind, + tags = substitute.tags, + content = substitute.content, + sig = "" + ) + ) + ), + sig = "" + ), + userPublicKey = other.publicKey + ) + + assertEquals( + before.map { it.eventId }, + other.items(session.id).map { it.eventId }, + "a re-proposal must be ignored, not applied" + ) + assertEquals(before.map { it.nonceRandom }, other.items(session.id).map { it.nonceRandom }) + } + /** Three dialects, distinct enough that an order bug shows up as a wrong name. */ private fun dialects() = listOf( Triple("Sepedi", "ZA", "nso"), diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md index 7bce1d6e..dac2f7f2 100644 --- a/docs/frost-batch-signing.md +++ b/docs/frost-batch-signing.md @@ -423,14 +423,28 @@ sharing costs is the secret share, to anyone who sees both partial signatures. It is the cheapest possible guard against the one mistake in this document that loses the key, and it catches an index bug nothing else here would. -Then, still to write: +Then two on the inbound path, driven by handing the manager a hand-built inner +event rather than one the other device queued — which is the only way to be a +faulty or dishonest member in this harness: -- a wrong-length payload is dropped rather than truncated; -- a second proposal under the same session id with a changed item is ignored; -- a `k = 3` batch in - [FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt), - against real FROST and no database, for the same reason that file exists at - all: the library calls are checked without a database in the way. +- **a wrong-length payload is left out, not truncated.** The length check is all + that stands between a batch and a signer whose contribution lines up against + the wrong messages. Assert that a one-value nonce for a three-item batch does + not count towards the threshold — and then that the real nonce replaces it and + the batch finishes, so it is a stall rather than damage. +- **a second proposal under the same id changes nothing.** Every item's seed is + already committed to that item's message; a different batch under the same id + would have those seeds produce a second partial signature over a second + message. + +And three in +[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt), +against real FROST with no database, for the same reason that file exists at +all — the library calls are checked with nothing in the way: a `k = 3` batch +from one signer set where all three verify, an item's signature refusing to +verify against its neighbour, and both halves of the no-shared-nonce property +(one seed under two messages gives two nonces, *and* the seeds differ anyway — +either alone is enough to be relied on by accident). --- From 1448ed5ad8a02ee3a0c6297f43c5f6b00bc00767 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 05:00:21 +0200 Subject: [PATCH 10/10] docs(frost): record batch signing as built, and what rollout needs Phase 7 of docs/frost-batch-signing.md, which is the phase with no code in it. Nothing needs a feature flag. k=1 is the entire behaviour of the app as shipped -- no caller batches anything yet -- and at k=1 every message is byte-identical to the app before Phase 1: encodeProposal returns the bare event object, joinPayload of one value is that value, and every plural branch in the transcript is only taken above one. The doc now tabulates that rather than asserting it in prose, since it is the claim the whole rollout rests on. The one rollout constraint stands: before a caller batches, the group has to be on a build that understands array proposals. There is no negotiation for it and adding one is not worth it -- an old device refuses an array proposal outright, so the failure mode is a batch that never reaches threshold and is abandoned, visible in the transcript and costing a retry. Also records what is left, which is nothing in the protocol: deciding what to batch is a product question, bounded only by "a batch is only as available as its worst item" and "GroupKeyStateManager.propose must never batch". The phases are kept as written rather than rewritten into a description of the result -- the code reads better against the argument it came from -- with the two places the implementation chose differently (itemIndex over index, DROP COLUMN over a table rebuild) marked in their own sections. Co-Authored-By: Claude Opus 5 --- docs/README.md | 4 ++-- docs/frost-batch-signing.md | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/README.md b/docs/README.md index 3ddbf229..a58845e7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,6 @@ Start with the ceremony if you are new to this area; the Marmot notes all assume Read the skipped-keys note before debugging any "the other device never got it" report — it is silent, and it looks like every other kind of delivery failure. The sync note stands alone, and the dead-code inventory reads as a follow-up to it. The -batch-signing note is a plan rather than a description of what is there: read it -after the derivation note, whose one rule is the same one it is built around. The +batch-signing note is a phased plan that has been built: read it after the +derivation note, whose one rule is the same one it is built around. The jvm-target note is unrelated to all of them: it is a build and packaging story. diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md index dac2f7f2..a55eb573 100644 --- a/docs/frost-batch-signing.md +++ b/docs/frost-batch-signing.md @@ -4,6 +4,14 @@ How to have a group sign several events in one ceremony instead of one at a time, phased, with the cryptographic constraint that shapes every phase stated first. +**Built.** All seven phases are in, one commit each, and the phases below are +kept as written -- they are the reasoning, and the code is easier to read +against the argument it came from than against a summary of itself. Where the +implementation chose differently from the first draft the section says so. +`FrostSigningManager.proposeSigningBatch` and +`FrostSigningRepository.proposeSigningBatch` are the entry points; nothing in +the app calls them yet, which is Phase 7's point. + The headline: **there is no such thing as one FROST signature over many messages, and no way to reuse a nonce across them.** What can be batched is the ceremony — the rounds, the group events, and the approval a human is asked for. @@ -452,18 +460,33 @@ either alone is enough to be relied on by accident). **No code.** -Nothing here needs a feature flag. `k = 1` is the entire behaviour of the app -after Phase 4, byte-identical on the wire to the app before Phase 1, and no -caller batches anything until one is written to. Phases 1 and 2 are shippable on -their own and worth shipping on their own — they are a schema move and a -refactor, and landing them apart from the wire change means a bisect over a -signing bug lands on one or the other rather than on all of it. +Nothing here needs a feature flag. `k = 1` is the entire behaviour of the app as +shipped — no caller batches anything yet — and at `k = 1` every message is +byte-identical to the app before Phase 1: + +| message | at `k = 1` | +|---|---| +| proposal | `encodeProposal` returns the bare event object (asserted in `FrostProposalCodecTest`) | +| nonce, signer set, partial, signature | `joinPayload` of one value is that value | +| chat transcript | every line's plural branch is only taken above 1 | + +Phases 1 and 2 are shippable on their own and were worth landing on their own — +a schema move and a refactor — because a bisect over a signing bug then lands on +one or the other rather than on all of it. Before the first caller batches, confirm the group is on a build that understands array proposals. There is no negotiation for this and adding one is -not worth it; the failure mode is a batch that never reaches threshold and is +not worth it: the failure mode is a batch that never reaches threshold and is abandoned, which is visible in the transcript and costs nothing but a retry. +### What is left, when a caller wants it + +Nothing in the protocol. The remaining work is deciding *what* to batch, which +is a product question this document deliberately does not answer — beyond the +one rule that a batch is only as available as its worst item, so events that do +not belong together should not travel together, and the one prohibition that +`GroupKeyStateManager.propose` must never batch. + --- ## Appendix — what was considered and rejected