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