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<EventTemplate<*>> 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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 04:50:35 +02:00
parent 59c34263b3
commit 426e53be9d
3 changed files with 68 additions and 4 deletions

View File

@@ -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<Array<String>>,
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<EventTemplate<*>>
): 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
}

View File

@@ -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,

View File

@@ -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<EventTemplate<*>>
): 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<EventTemplate<*>>
): FrostSigningSession? = null
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit