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