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 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 04:54:12 +02:00
parent 426e53be9d
commit 9ac4bcbee3
2 changed files with 103 additions and 24 deletions

View File

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

View File

@@ -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<Event>, 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
)
}
}