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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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