Merge branch 'mantra' into claude/artifact-frost-signing-proposal-a414f6

This commit is contained in:
Kgothatso Ngako
2026-09-06 01:38:08 +02:00
6 changed files with 486 additions and 64 deletions

View File

@@ -241,6 +241,21 @@ data class ChatMessage(
/** The signing lines that ask rather than report. */
val FROST_REQUEST_TYPES = setOf(TYPE_FROST_APPROVAL_NEEDED)
/**
* The signing lines that end a session, whichever way it went.
*
* A request usually stops being one by being answered, but that is not
* the only way. A member who declines publishes nothing, and a quorum
* that signs without them asks them for nothing further -- from the
* transcript both look the same, nothing of the reader's own either
* side of the request, so the session's own ending is the only thing
* left to read it from.
*
* A ceremony has no equivalent because a ceremony step can only be
* taken or waited for. Signing is the one thing a member can refuse.
*/
val FROST_SETTLEMENTS = setOf(TYPE_FROST_COMPLETE, TYPE_FROST_FAILED)
/** Every signing line, for rendering them as system lines rather than bubbles. */
val FROST_TYPES = setOf(
TYPE_FROST_STARTED,
@@ -278,6 +293,49 @@ data class ChatMessage(
TYPE_DKG_FAILED,
)
/**
* The request lines this device has since answered, by row id.
*
* A request is answered when the step it asked for has been published by
* this device -- which is exactly what approving it does. The transcript
* already records that as an authored line, so the answer is read from
* the list rather than from the session, and it stays right for a room
* that has run more than one ceremony.
*/
fun answeredRequests(messages: List<ChatMessage>): Set<Long> = messages
.mapNotNull { request ->
val published = (DKG_REQUEST_FULFILMENTS + FROST_REQUEST_FULFILMENTS)[request.messageType]
?: return@mapNotNull null
val done = messages.any {
it.messageType == published &&
it.isUserMessage &&
it.createdAt >= request.createdAt
}
request.id.takeIf { done }
}
.toSet()
/**
* The signing requests that are no longer open, by row id.
*
* Not the same thing as [answeredRequests], and deliberately kept apart
* from it: these are requests the reader never answered, so they have
* earned no tick and claiming otherwise would credit them with a
* signature they refused or were never needed for. What they have run
* out of is a decision to make -- see [FROST_SETTLEMENTS].
*/
fun settledRequests(messages: List<ChatMessage>): Set<Long> = messages
.filter { request ->
request.messageType in FROST_REQUEST_TYPES &&
messages.any {
it.messageType in FROST_SETTLEMENTS && it.createdAt >= request.createdAt
}
}
.map { it.id }
.toSet()
/**
* Files one direct message, from whichever side of it this device is on.
*

View File

@@ -464,6 +464,19 @@ object FrostSigningManager {
?: return
try {
// A signature the group has already made settles this session whether
// or not its owner ever answered: a t-of-n key does not need everybody,
// so a quorum can finish while one member's phone is still in a pocket.
// Ahead of the gate below because that gate is about keeping this
// device's own material off the wire, and finishing puts none of it
// 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) {
complete(database, session)
return
}
// Nothing of this device's own goes out before its owner has said so.
// Returns rather than throws: the session is not failing, it is waiting
// on a person, and everything received stays stored so it resumes the
@@ -580,43 +593,7 @@ object FrostSigningManager {
announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey)
}
val signature = session.signature ?: return
// The payoff: a signature that verifies is one the group made, whoever
// relayed it. Checking rather than trusting is what keeps a faulty or
// dishonest coordinator from passing off something that will be
// rejected by every relay it reaches.
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}")
}
update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) }
// A signature exists to be used. Every device has the event and the
// signature by now, so each applies the result itself rather than
// waiting to be sent something it can already build -- the same
// reasoning the transcript lines are written on. Nothing goes on the
// 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)
announce(
database = database,
session = session,
messageType = ChatMessage.TYPE_FROST_COMPLETE,
content = "The group signed the event. It took ${session.threshold} of " +
"${session.participantCount} members.",
actor = session.coordinatorPublicKey
)
logger.i("Signing session $sessionId complete")
complete(database, session)
} catch (e: CancellationException) {
// The sync was torn down mid-step, which says nothing about the session.
throw e
@@ -633,6 +610,54 @@ object FrostSigningManager {
}
}
/**
* Verifies the group's signature, uses the event, and closes the session.
*
* The payoff: a signature that verifies is one the group made, whoever
* relayed it. Checking rather than trusting is what keeps a faulty or
* dishonest coordinator from passing off something that will be rejected by
* every relay it reaches.
*
* Reached only with the session still open -- [advance] returns above this
* on a settled one -- so the milestone is written once by construction,
* like every other line in the transcript.
*/
private suspend fun complete(database: MantraDatabase, session: FrostSigningSession) {
val signature = session.signature ?: 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}")
}
update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) }
// A signature exists to be used. Every device has the event and the
// signature by now, so each applies the result itself rather than
// waiting to be sent something it can already build -- the same
// reasoning the transcript lines are written on. Nothing goes on the
// 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)
announce(
database = database,
session = session,
messageType = ChatMessage.TYPE_FROST_COMPLETE,
content = "The group signed the event. It took ${session.threshold} of " +
"${session.participantCount} members.",
actor = session.coordinatorPublicKey
)
logger.i("Signing session ${session.id} complete")
}
/**
* Turns the signed event into whatever it is: a dialect, an artifact, a
* chapter.
@@ -974,6 +999,12 @@ object FrostSigningManager {
return false
}
// The group signed it without needing this member, and [advance] closes
// 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
return session.signApprovedAt == null
}

View File

@@ -48,6 +48,7 @@ import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
@@ -223,10 +224,10 @@ fun FrostSigningScreen(
}
}
if (session.signApprovedAt == null &&
session.stage != FrostSigningStage.COMPLETE &&
session.stage != FrostSigningStage.FAILED
) {
// 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)) {
HorizontalDivider()
Text(

View File

@@ -283,30 +283,17 @@ class ChatMessageListViewModel(
)
} else {
// A request line is answered when the step it asked for
// has since been published by this device -- which is
// exactly what approving it does. The transcript already
// records that as an authored line, so the answer is here
// in the list rather than in the session, and it stays
// right for a room that has run more than one ceremony.
val answeredRequests = chatRoomDetailMessageListUIState
// Which request lines are still asking something of the
// reader. Read off the transcript rather than the session
// -- the rows are what a line rendered days later has --
// and both rules live on ChatMessage, where they can be
// stated once and tested.
val messages = chatRoomDetailMessageListUIState
.chatMessageList
.mapNotNull { request ->
val published = (
ChatMessage.DKG_REQUEST_FULFILMENTS +
ChatMessage.FROST_REQUEST_FULFILMENTS
)[request.chatMessage.messageType]
?: return@mapNotNull null
.map { it.chatMessage }
val done = chatRoomDetailMessageListUIState.chatMessageList.any {
it.chatMessage.messageType == published &&
it.chatMessage.isUserMessage &&
it.chatMessage.createdAt >= request.chatMessage.createdAt
}
request.chatMessage.id.takeIf { done }
}
.toSet()
val answeredRequests = ChatMessage.answeredRequests(messages)
val settledRequests = ChatMessage.settledRequests(messages)
LazyColumn(
modifier = Modifier.fillMaxWidth().padding(5.dp),
@@ -366,6 +353,7 @@ class ChatMessageListViewModel(
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
isSettled = localChatMessage.chatMessage.id in settledRequests,
onClick = onOpenSharedKey
)
return@items
@@ -394,6 +382,7 @@ class ChatMessageListViewModel(
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
isSettled = localChatMessage.chatMessage.id in settledRequests,
onClick = onOpenSigning
)
return@items
@@ -663,6 +652,7 @@ private fun PrivateMessageNotice(
private fun RitualNotice(
localChatMessage: LocalChatMessage,
isAnswered: Boolean,
isSettled: Boolean,
onClick: () -> Unit,
) {
val chatMessage = localChatMessage.chatMessage
@@ -704,10 +694,13 @@ private fun RitualNotice(
// quiet; these are not.
// An answered request is history, not a summons: it keeps its stage's icon so
// the step is still recognisable, but drops the colour and the call to action.
// So is a settled one -- declined, or signed by a quorum that did not need this
// member. Nothing was answered there, so it gets no tick, but offering to
// review it would be offering a decision that has already gone by.
val isRequest = (
chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES ||
chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES
) && !isAnswered
) && !isAnswered && !isSettled
val tint = when {
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||