fix: stop asking a member to review a signature the group has settled
The transcript's "Review" affordance is a promise: tapping it leads to a
decision still there to be made. For a FROST signing proposal it was only
ever withdrawn one way -- and a proposal can be processed three.
**How a request was closed.** RitualNotice drops the tint and the call to
action when the request is answered, and a request counts as answered when
the step it asked for has since been published by this device:
FROST_REQUEST_FULFILMENTS = mapOf(TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE)
Approving publishes a nonce, so approving closes it. Nothing else does.
**Declining.** decline() fails the session and broadcasts a FAILURE. It
publishes nothing of the member's own, by design -- a refusal is a refusal.
So no fulfilment line is ever written, and the request went on asking, in
primary tint, for a decision the member had already made. Tapping it
reached a screen with no buttons on it, which was the screen being right.
**A quorum that did not need them.** A t-of-n key finishes without
everybody. The coordinator takes the first t nonces, and a member whose
phone was in a pocket is simply not among them -- but advance() returned at
the approval gate on their device, so the arriving SIGNATURE was stored and
nothing was done with it. Their session sat at COLLECTING_NONCES forever.
The request stayed lit, the screen still offered Sign and Don't sign, and
both answers were wrong: a nonce nobody was waiting for, or a refusal that
would flip a COMPLETE session to FAILED on every device and announce
"Nothing was signed" to a group holding the signature. fail() writes the
stage with update() rather than moveTo(), so that last one was reachable.
**The transcript.** A request is now closed by being *answered* or by being
*settled* -- a frostComplete or frostFailed line after it. The two are kept
apart deliberately. Answered keeps the tick; settled does not, because the
member never answered and crediting them with a signature they refused, or
were never asked for, is worse than the summons was. Both rules moved out
of the composable onto ChatMessage, where they are stated once and tested.
Settlement is signing-only: a ceremony step can only be taken or waited
for, so a DKG request has no equivalent and reading one from a signing
session's end would drop a summons the ritual is still stalled on.
**The session.** The transcript alone could not close the third case: the
device that never approved wrote no terminal line to read. advance() now
completes on a signature that has already arrived, ahead of the approval
gate rather than below it. That gate is there to keep this device's own
material off the wire, and finishing puts none there -- it verifies the
aggregate, applies the event and announces, all from what is already
stored. Everything it now skips on that path is work the signature made
pointless anyway: a late nonce, a partial signature nobody will aggregate.
Three things follow. isAwaitingApproval reports false, so FrostSigningScreen
hides the buttons -- it now asks the manager rather than re-deriving the
rule, which had drifted into a second copy of it. A late "Don't sign"
cannot abandon a signature that exists. And the signed event finally lands
locally for a member who never approved: applySignedEvent sat below the
gate and was being skipped, so a dialect the group signed without them
never reached their store.
Verified: :composeApp:compileDebugKotlinAndroid succeeds, and
:composeApp:testDebugUnitTest passes -- 165 tests, 16 of them new. Eight
cover the transcript rules against a hand-built row list; eight cover
isAwaitingApproval, including the settled-signature case. What stays
uncovered is advance() itself, which is Room-backed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package press.mantra.compose.database.model
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* When a request line in a transcript stops asking for something.
|
||||
*
|
||||
* The transcript renders a request with a tint and a "Review" affordance, and
|
||||
* that is a promise: tapping it leads to a decision still there to be made.
|
||||
* Keeping the promise means knowing when the decision has gone, and the rows are
|
||||
* all there is to know it from -- a line rendered days later has no session to
|
||||
* ask, and the room may have signed several things since.
|
||||
*
|
||||
* Two ways for a request to be over, and they are not the same. Answering it
|
||||
* leaves a line of the reader's own and earns the tick. A session ending
|
||||
* underneath it leaves nothing of theirs at all: a member who declined published
|
||||
* nothing, and a quorum that signed without them wanted nothing. Both must drop
|
||||
* the summons; neither may claim the member signed.
|
||||
*/
|
||||
class TranscriptRequestStateTest {
|
||||
private val user = "u".repeat(64)
|
||||
private val other = "o".repeat(64)
|
||||
|
||||
private var lastId = 0L
|
||||
|
||||
/** One transcript row, with only the four fields either rule reads. */
|
||||
private fun line(
|
||||
type: String,
|
||||
at: Long,
|
||||
sender: String = user
|
||||
) = ChatMessage(
|
||||
id = ++lastId,
|
||||
senderPublicKey = sender,
|
||||
isUserMessage = sender == user,
|
||||
giftWrapPayloadId = null,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null,
|
||||
chatRoomId = "room",
|
||||
content = "",
|
||||
messageType = type,
|
||||
createdAt = Instant.fromEpochSeconds(at)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a signing request nobody has acted on is still asking`() {
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(line(ChatMessage.TYPE_FROST_STARTED, at = 9, sender = other), request)
|
||||
|
||||
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
|
||||
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `publishing the nonce answers the request that asked for it`() {
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11))
|
||||
|
||||
assertEquals(setOf(request.id), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `somebody else's nonce answers nothing`() {
|
||||
// The fulfilment has to be this device's own: a transcript is full of other
|
||||
// members taking the step this reader has yet to take.
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_NONCE, at = 11, sender = other))
|
||||
|
||||
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declining settles the request without claiming it was signed`() {
|
||||
// Declining publishes nothing, so there is no fulfilment to find. The
|
||||
// failure the refusal writes is the only trace, and it has to be enough --
|
||||
// otherwise the line goes on offering a decision already made.
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_FAILED, at = 11))
|
||||
|
||||
assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript))
|
||||
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a group that signs without this member settles their request`() {
|
||||
// A t-of-n key does not need everybody. Nothing of this member's is in the
|
||||
// signature and nothing of theirs was ever published, so answered stays
|
||||
// empty -- but there is no longer anything for them to decide.
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 10)
|
||||
val transcript = listOf(request, line(ChatMessage.TYPE_FROST_COMPLETE, at = 12, sender = other))
|
||||
|
||||
assertEquals(setOf(request.id), ChatMessage.settledRequests(transcript))
|
||||
assertEquals(emptySet(), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an earlier session's ending does not close a later request`() {
|
||||
// Rooms sign more than once, and the previous session's last line sits
|
||||
// above this one's first.
|
||||
val request = line(ChatMessage.TYPE_FROST_APPROVAL_NEEDED, at = 20)
|
||||
val transcript = listOf(line(ChatMessage.TYPE_FROST_COMPLETE, at = 9, sender = other), request)
|
||||
|
||||
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ceremony step is settled by nothing`() {
|
||||
// Signing is the one thing a member can refuse, so it is the only place a
|
||||
// request can be over without them having answered it. A ceremony step is
|
||||
// either taken or still waited on, and reading either ending as the end of
|
||||
// one would drop a summons the ritual is still stalled on.
|
||||
val request = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 10)
|
||||
val transcript = listOf(
|
||||
request,
|
||||
line(ChatMessage.TYPE_FROST_FAILED, at = 11),
|
||||
line(ChatMessage.TYPE_DKG_FAILED, at = 12, sender = other)
|
||||
)
|
||||
|
||||
assertEquals(emptySet(), ChatMessage.settledRequests(transcript))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each ceremony step is answered only by its own`() {
|
||||
val hostKey = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY, at = 10)
|
||||
val roundOne = line(ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1, at = 12)
|
||||
val transcript = listOf(hostKey, line(ChatMessage.TYPE_DKG_HOST_KEY, at = 11), roundOne)
|
||||
|
||||
assertEquals(setOf(hostKey.id), ChatMessage.answeredRequests(transcript))
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,13 @@ import fr.acinq.bitcoin.crypto.frost.Session
|
||||
import fr.acinq.bitcoin.crypto.frost.TweakCache
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlin.test.Test
|
||||
import kotlin.time.Instant
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.types.FrostSigningStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
|
||||
@@ -217,6 +219,39 @@ class FrostSigningSessionTest {
|
||||
signerIds = signerIds
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a session waits on its owner until they answer`() {
|
||||
val open = session(signerId = 2, signerIds = null)
|
||||
|
||||
assertTrue(FrostSigningManager.isAwaitingApproval(open))
|
||||
assertFalse(
|
||||
FrostSigningManager.isAwaitingApproval(
|
||||
open.copy(signApprovedAt = Instant.fromEpochSeconds(1))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session that has settled asks its owner nothing`() {
|
||||
val open = session(signerId = 2, signerIds = null)
|
||||
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE)))
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a signature the group already made asks its owner nothing either`() {
|
||||
// A t-of-n key does not need everybody, so a quorum can finish while one
|
||||
// member's phone is still in a pocket. The session stays at its opening
|
||||
// stage on their device until it next advances, and offering them the
|
||||
// decision in that window offers two bad answers: a nonce nobody is
|
||||
// waiting for, or a refusal that abandons a signature that exists.
|
||||
val signedWithoutThem = session(signerId = 2, signerIds = "0,1")
|
||||
.copy(signature = "a".repeat(128))
|
||||
|
||||
assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a member left out of the signer set is not a signer`() {
|
||||
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
|
||||
|
||||
Reference in New Issue
Block a user