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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user