fix(subgroups): a parent's second subgroup can be certified, and sign its own key state

Both rungs after a subgroup's ceremony run in the **parent's** room. The birth
certificate is the parent's signature, so it is signed where the parent's key is; the
key state is signed over the parent's room because the room it is *about* does not
exist yet -- that is the whole point of signing it first. So a parent making its second
subgroup has two certificate signings and two key-state signings in one room, and the
ceremony screen read them out by event kind alone.

`observeSessionsForChatRoom` is `ORDER BY createdAt DESC`, so `firstOrNull` on a kind
match is "the newest of that kind in this room" -- which is some other subgroup's. The
screen for one child followed another child's progress.

**The display was not the damage.** Both rungs disable their button while a signing of
that kind is running and un-failed:

```kotlin
enabled = … && (certificateSession == null || certificateFailed)
```

and a *completed* signing is un-failed forever. So once a parent had certified one
subgroup, it could never be asked to certify another: the rung sat there disabled with
no way out, and `proposeBirthCertificate` returned early on the same reading. The key
state blocked the same way, and `createAdminGroup` is gated behind it, so the second
subgroup could not be made at all.

**It did not need two subgroups.** Adoption signs a key state in the child's own room
(`proposeAcceptance`, `localChatRoom = childRoom`), so a group that has ever been
adopted already holds a `GroupKeyStateEvent` session in its room -- enough to block the
first subgroup it tries to make.

What makes this a half-finished job rather than an oversight is that the *signed* facts
were already right. `isCertified` goes through `observeBirthCertificate`, scoped by
session id, and `isKeyStateSigned` through `observeSignedGroupKeyState`, whose own
comment records this exact lesson being learned once already: "A ceremony room holds
more than one ceremony the moment a subgroup's admins are the whole group." The
in-flight lookups never got the same treatment, and the comment above the key-state
watcher still asserted the premise that had stopped being true -- "the room a ceremony
runs in signs nothing else".

**The key state is matched on the ceremony the proposal names.** `GroupKeyStateEvent`
carries `FrostKeyTag` from the moment the proposal is assembled, so
`parseDkgSessionId(tags) == dkgSessionId` discriminates before the ceremony has a key
and before anybody signs. The watcher moves out of `observeKeyState` -- deleted, with
its `Job` -- and into `observeForCeremony`, which is already cancelled and re-pointed
per ceremony and is where every other per-ceremony watcher lives. `observeForCeremony`'s
null branch now clears `keyStateSession` too, which it could not before because the
field was not its to clear.

**The certificate is matched on the child it names, through a new `addresses`.**
`certifies` could not be reused: it ends on `GroupKeyStateEvent.isSignedByRoom`, a real
signature check by the parent's quorum, and the thing being matched here is a proposal
that quorum has not signed yet -- that is what the rung is waiting for.
`SubgroupBirthCertificateEvent.addresses` is the structural half (kind, content, the `d`
tag, the parent tag), `certifies` now calls it so the two cannot drift, and its doc says
outright that nothing may decide a lineage on it. The three values it reads come out of
`assembleTags` together, so they agree by construction rather than by agreement.

Correctness is not at stake in either: these two fields only decide which progress bar
is drawn and which button is live. Whether the parent actually agreed is `isCertified`,
which does check the signature.

**`DkgRepository.roomDerivedBy(dkgSessionId)`** is the session-id-to-room derivation
that `observeBirthCertificate` and `observeSignedGroupKeyState` each carried inline.
Three callers now, one of them new, and the no-op repository answers null -- which is
also the honest answer for a ceremony that has not finished, and the reason all three
resolve it per emission rather than once.

**Tests: `DkgRitualSecondSubgroupJvmTest`, 3 cases, each checked against the bug
reinstated rather than only against the fix.** Two assert that the screen follows its
own ceremony's signing when a sibling's is newer. The third is the permanent block, and
it is driven in two emissions on a hot flow for a reason worth recording: written as a
single emission it asserted "still null" and **passed against the bug**, because at
that moment the watcher had not started. Nothing is proved by a field that is null
before anybody has looked at it.

**One test from the commit before this had to be rewritten, and the change is this
commit's fault.** `DkgRitualStartOfferJvmTest`'s lost-update case forced its interleaving
by parking the session watcher inside `pendingApproval` while the key-state watcher
wrote. Those two are no longer concurrent -- the key-state watcher now starts *from* the
session collector, after the park -- so the setup could not happen. The claim was
untouched by that, so it is now made against the session watcher versus the room
watcher, which are genuinely concurrent: both collect before either writes, neither
waits on the other, and the field they contend over is `roomHasCeremony`, which is the
one that decides whether the offer to start appears. Re-confirmed failing against the
lost update reinstated.

973 jvm tests, 454 common tests, `m3Audit` meeting every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 20:12:43 +02:00
parent a14ea3d9d3
commit 26d305bc14
6 changed files with 511 additions and 117 deletions

View File

@@ -110,26 +110,27 @@ class DatabaseDkgRepository(
): Flow<List<LocalFrostSigningSession>> =
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
/**
* By session id, not by room. A ceremony room holds more than one ceremony
* the moment a subgroup's admins are the whole group, and a lookup by room
* answered about whichever the room's rule picked -- for a room holding only
* a subgroup's ceremony, asking for the room's *own* found nothing at all,
* and a finished subgroup with a signed key state could never be created.
*/
override suspend fun roomDerivedBy(dkgSessionId: String): String? =
database.dkgSessionDao()
.getSessionById(dkgSessionId)
?.thresholdPublicKey
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
override fun observeSignedGroupKeyState(dkgSessionId: String): Flow<LocalGroupKeyState?> =
database.groupSignedEventDao()
.observeByKind(GroupKeyStateEvent.KIND)
.map { signedEvents ->
// The room this ceremony's key derives, resolved per emission
// rather than once: the ceremony has no key until it finishes,
// and this flow is running before it does.
//
// By session id, not by room. A ceremony room holds more than one
// ceremony the moment a subgroup's admins are the whole group, and
// a lookup by room answered about whichever the room's rule picked
// -- for a room holding only a subgroup's ceremony, asking for the
// room's *own* found nothing at all, and a finished subgroup with
// a signed key state could never be created.
val subjectRoomId = database.dkgSessionDao()
.getSessionById(dkgSessionId)
?.thresholdPublicKey
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
// Resolved per emission rather than once: the ceremony has no key
// until it finishes, and this flow is running before it does.
roomDerivedBy(dkgSessionId)
?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
}
override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try {
@@ -176,14 +177,9 @@ class DatabaseDkgRepository(
database.groupSignedEventDao()
.observeByKind(SubgroupBirthCertificateEvent.KIND)
.map { signedEvents ->
// The room the ceremony's key derives, resolved per emission
// rather than once: the ceremony has no key until it finishes,
// and this flow is running before it does.
val subgroupChatRoomId = database.dkgSessionDao()
.getSessionById(dkgSessionId)
?.thresholdPublicKey
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
?: return@map null
// Resolved per emission rather than once: the ceremony has no key
// until it finishes, and this flow is running before it does.
val subgroupChatRoomId = roomDerivedBy(dkgSessionId) ?: return@map null
signedEvents
.filter {

View File

@@ -170,15 +170,7 @@ object SubgroupBirthCertificateEvent {
subgroupChatRoomId: String,
parentChatRoomId: String
): Boolean = runCatching {
if (event.kind != KIND) return false
if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false
val addressed = parseSubgroupChatRoomId(event.tags) ?: return false
if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false
val named = parseParentChatRoomId(event.tags) ?: return false
if (!named.equals(parentChatRoomId, ignoreCase = true)) return false
if (!addresses(event, subgroupChatRoomId, parentChatRoomId)) return false
val thresholdPublicKey = parseThresholdPublicKey(event.tags) ?: return false
val path = parsePath(event.tags) ?: return false
@@ -189,6 +181,38 @@ object SubgroupBirthCertificateEvent {
GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId)
}.getOrDefault(false)
/**
* Whether this event is *addressed to* that child by that parent -- the
* half of [certifies] that can be asked of a proposal nobody has signed.
*
* **Not a weaker [certifies].** It says who the event is about, not that
* the parent's quorum agreed to it: no derivation and no signature, so an
* event that merely claims the right pair passes. Nothing may decide a
* lineage on this.
*
* What it is for is telling one *in-flight* certificate signing from
* another in the same room. A parent certifies every subgroup it ever makes
* from one room, so the ceremony screen has several to choose between and
* no signature to choose by -- the proposal is what the quorum has not yet
* signed. The three values it reads are assembled together by
* [assembleTags], so they agree by construction.
*/
fun addresses(
event: Event,
subgroupChatRoomId: String,
parentChatRoomId: String
): Boolean = runCatching {
if (event.kind != KIND) return false
if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false
val addressed = parseSubgroupChatRoomId(event.tags) ?: return false
if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false
val named = parseParentChatRoomId(event.tags) ?: return false
named.equals(parentChatRoomId, ignoreCase = true)
}.getOrDefault(false)
/**
* The name tag. A bare two-element tag with no class of its own, because
* nothing but the proposal screen and the subgroup list ever reads it and

View File

@@ -65,6 +65,19 @@ interface DkgRepository {
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
/**
* The id of the room a ceremony's key derives, or null while it has no key.
*
* The one name a ceremony and the room it makes have in common, and the only
* way to tell two of a parent's subgroups apart in anything written about
* the room rather than about the ceremony -- a birth certificate names the
* child, not the ceremony that produced it.
*
* Null is the ordinary answer rather than a failure: a ceremony has no key
* until it finishes, and every caller here is watching from before that.
*/
suspend fun roomDerivedBy(dkgSessionId: String): String?
/**
* Opens a ritual in [localChatRoom], making this device the coordinator.
*
@@ -213,6 +226,8 @@ interface DkgRepository {
override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null
override suspend fun roomDerivedBy(dkgSessionId: String): String? = null
override suspend fun proposeRitual(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,

View File

@@ -114,9 +114,6 @@ class DkgRitualViewModel(
/** Watches the current session's messages; replaced whenever the session changes. */
private var messageObserver: Job? = null
/** Watches the room's signing sessions, which is where the key state is agreed. */
private var keyStateObserver: Job? = null
/** Watches the parent certifying this subgroup; re-pointed as the purpose is learned. */
private var certificateObserver: Job? = null
@@ -240,8 +237,6 @@ class DkgRitualViewModel(
.defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
.coerceIn(quorumRange())
observeKeyState()
launch {
roomSessions.collect { newest ->
updateLoaded { current ->
@@ -323,43 +318,6 @@ class DkgRitualViewModel(
}
}
/**
* Follows the group agreeing what its admin room will sign with.
*
* Nothing here watches a [GroupKeyState] row, because there is no room yet
* for one to belong to -- being able to follow this before the room exists
* is the point. What is watched instead is the session, for how the signing
* is going, and the signed event, for whether it finished.
*
* The room a ceremony runs in signs nothing else, so the newest session in
* it is the key state's -- but it is matched on the kind it carries anyway,
* so a room that one day signs something else does not confuse the two.
*/
private fun observeKeyState() {
keyStateObserver?.cancel()
keyStateObserver = viewModelScope.launch(Dispatchers.IO) {
// Two flows because they answer two questions off two tables. How
// the signing is going is the session row; whether it finished is
// the signed event, and only that second one can be asked at all
// before there is a room -- which is the whole point of asking it.
launch {
dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
val keyState = sessions.firstOrNull { local ->
local.items.any { item ->
runCatching {
Event.fromJson(item.unsignedEventJson).kind
}.getOrNull() == GroupKeyStateEvent.KIND
}
}?.session
updateLoaded { it.copy(keyStateSession = keyState) }
}
}
}
}
/**
* Follows one ceremony: whether its key state has been signed, and -- for a
* subgroup -- whether its parent has certified it.
@@ -379,12 +337,50 @@ class DkgRitualViewModel(
if (dkgSessionId == null) {
updateLoaded {
it.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null)
it.copy(
isKeyStateSigned = false,
keyStateSession = null,
isCertified = false,
certificateSession = null
)
}
return
}
certificateObserver = viewModelScope.launch(Dispatchers.IO) {
// How the agreement is going, as opposed to whether it finished --
// two questions off two tables, and only the second can be asked
// before there is a room at all, which is the point of asking it.
//
// By the ceremony the proposal names, not by kind in this room. The
// room a ceremony runs in used to sign nothing else, and that is
// what this read assumed; a parent's Marmot room now hosts the
// ceremony of every subgroup the group makes, so it holds one of
// these per subgroup. Matching on kind alone handed the screen the
// newest of them -- which is some other subgroup's -- and with it a
// progress bar for a signing this ceremony is not in and a button
// disabled on the strength of it. Permanently, once that other one
// completed: the guard only reopens on a failure.
//
// `FrostKeyTag` is on the proposal from the moment it is made, so
// this works before the ceremony has a key and before anybody signs.
launch {
dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
val keyState = sessions.firstOrNull { local ->
local.items.any { item ->
val event = runCatching {
Event.fromJson(item.unsignedEventJson)
}.getOrNull() ?: return@any false
event.kind == GroupKeyStateEvent.KIND &&
GroupKeyStateEvent.parseDkgSessionId(event.tags) == dkgSessionId
}
}?.session
updateLoaded { it.copy(keyStateSession = keyState) }
}
}
// Whether the group has agreed what the room this ceremony's key
// derives will sign with. Asked by ceremony rather than by room: the
// room holds however many ceremonies it holds, and the answer is
@@ -408,14 +404,43 @@ class DkgRitualViewModel(
}
launch {
// The child this ceremony is making, which is what a certificate
// names. Resolved per emission rather than once, because the
// ceremony has no key until it finishes and this flow is running
// before it does -- the same reason `observeBirthCertificate`
// resolves it per emission.
//
// Scoped to that child, not to the kind. A parent certifies every
// subgroup it ever makes from the same room, so by kind alone this
// found the newest certificate signing in the parent -- some other
// child's -- and read its progress as this one's. Worse, the rung
// disables "ask the group to certify" while a certificate session
// is running and un-failed, so once a parent had certified one
// subgroup it could never be asked to certify another.
dkgRepository.observeSigningSessions(parentChatRoomId).collect { sessions ->
val certificate = sessions.firstOrNull { local ->
local.items.any { item ->
runCatching {
Event.fromJson(item.unsignedEventJson).kind
}.getOrNull() == SubgroupBirthCertificateEvent.KIND
}
}?.session
val subgroupChatRoomId = dkgRepository.roomDerivedBy(dkgSessionId)
val certificate = subgroupChatRoomId?.let { child ->
sessions.firstOrNull { local ->
local.items.any { item ->
val event = runCatching {
Event.fromJson(item.unsignedEventJson)
}.getOrNull() ?: return@any false
// `addresses`, not `certifies`: the thing being
// matched is a proposal, and `certifies` ends on
// a signature by the parent's quorum, which is
// precisely what has not happened yet. Whether
// the parent actually agreed is `isCertified`,
// which does check it.
SubgroupBirthCertificateEvent.addresses(
event = event,
subgroupChatRoomId = child,
parentChatRoomId = parentChatRoomId,
)
}
}?.session
}
updateLoaded { it.copy(certificateSession = certificate) }
}
@@ -736,7 +761,6 @@ class DkgRitualViewModel(
override fun onCleared() {
messageObserver?.cancel()
keyStateObserver?.cancel()
certificateObserver?.cancel()
super.onCleared()
}

View File

@@ -0,0 +1,322 @@
package press.mantra.compose.ui.view.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalParticipant
import press.mantra.compose.database.model.types.DkgRitualStage
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.view.state.DkgRitualUIState
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* A parent's room holds every one of its subgroups' signings, and the ceremony
* screen has to pick out the one it is showing.
*
* Since a subgroup's ChillDKG moved into its parent's Marmot room, both of the
* rungs after the ceremony run there too: the birth certificate is the parent's
* signature, and the key state is signed over the parent's room because the
* subgroup's own room does not exist yet. So a parent making a second subgroup
* has two of each in one room, told apart by nothing but the ceremony they
* belong to.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class DkgRitualSecondSubgroupJvmTest {
private val parentChatRoomId = "aa".repeat(32)
private val me = "11".repeat(32)
private val peer = "22".repeat(32)
// Real points, in the compressed form `SharedKeyDerivation` reads: it derives
// a room id off the curve, and a made-up 32 bytes is not on it.
private val firstKey = "02" + KeyPair().pubKey.toHexKey()
private val secondKey = "02" + KeyPair().pubKey.toHexKey()
@BeforeTest
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `the certificate shown is this subgroup's, not the parent's newest`() = runBlocking {
val loaded = load(
signingSessions = listOf(
// Newest first, the way the query returns them.
certificateSigning("forSecond", subgroupOf(secondKey)),
certificateSigning("forFirst", subgroupOf(firstKey)),
),
until = { it.certificateSession != null }
)
assertEquals(
"forFirst",
loaded.certificateSession?.id,
"the screen for one subgroup is following another subgroup's certificate"
)
}
@Test
fun `the key state shown is this ceremony's, not the room's newest`() = runBlocking {
val loaded = load(
signingSessions = listOf(
keyStateSigning("forSecond", dkgSessionId = "secondCeremony"),
keyStateSigning("forFirst", dkgSessionId = "firstCeremony"),
),
until = { it.keyStateSession != null }
)
assertEquals(
"forFirst",
loaded.keyStateSession?.id,
"the screen for one ceremony is following another ceremony's key state"
)
}
/**
* The damaging half, and the reason this is a block rather than a cosmetic
* mix-up. Both rungs disable their button while a signing of that kind is
* running and un-failed, and a *completed* signing is un-failed forever --
* so by kind alone, a parent that had certified one subgroup could never be
* asked to certify another, and the rung sat there disabled with no way out.
*/
@Test
fun `a sibling's finished signings leave this ceremony's rungs open`() = runBlocking {
// Driven in two emissions rather than one, so the assertion is about a
// watcher that demonstrably ran. Asserting "still null" against a single
// emission passes before the watcher has started, which is how a version
// of this test passed against the bug it was written for.
val signingSessions = MutableStateFlow(
listOf(
certificateSigning("mine", subgroupOf(firstKey)),
keyStateSigning("myKeyState", dkgSessionId = "firstCeremony"),
)
)
val viewModel = viewModel(signingSessions)
viewModel.initiate()
awaitLoaded(viewModel) {
it.certificateSession?.id == "mine" && it.keyStateSession?.id == "myKeyState"
}
// Now the room holds only a sibling's, both finished -- which is every
// parent that has already made one subgroup and is making its second.
signingSessions.value = listOf(
certificateSigning(
"siblingCertificate",
subgroupOf(secondKey),
stage = FrostSigningStage.COMPLETE
),
// A key state naming another ceremony: the sibling's, or this parent's
// own from an adoption, which is signed in this room too.
keyStateSigning(
"siblingKeyState",
dkgSessionId = "secondCeremony",
stage = FrostSigningStage.COMPLETE
),
)
val loaded = awaitLoaded(viewModel) {
it.certificateSession == null && it.keyStateSession == null
}
// Both rungs gate on these being null or failed, and a completed signing
// is never failed -- so a sibling left in either of them shuts the rung
// for good.
assertNull(loaded.certificateSession)
assertNull(loaded.keyStateSession)
}
private suspend fun load(
signingSessions: List<LocalFrostSigningSession>,
until: (DkgRitualUIState.Loaded) -> Boolean,
): DkgRitualUIState.Loaded {
val viewModel = viewModel(MutableStateFlow(signingSessions))
viewModel.initiate()
return awaitLoaded(viewModel, until)
}
private suspend fun awaitLoaded(
viewModel: DkgRitualViewModel,
until: (DkgRitualUIState.Loaded) -> Boolean,
): DkgRitualUIState.Loaded = withTimeout(5_000) {
viewModel.dkgRitualUIState.first {
(it as? DkgRitualUIState.Loaded)?.let(until) == true
}
} as DkgRitualUIState.Loaded
/** The screen is always showing the FIRST subgroup's ceremony. */
private fun viewModel(
signingSessions: MutableStateFlow<List<LocalFrostSigningSession>>
): DkgRitualViewModel {
val ceremony = DkgSession(
id = "firstCeremony",
chatRoomId = parentChatRoomId,
coordinatorPublicKey = me,
userPublicKey = me,
threshold = 2,
participantCount = 2,
hostPublicKey = me,
round1Random = "66".repeat(32),
round2AuxRandom = "77".repeat(32),
stage = DkgRitualStage.COMPLETE,
thresholdPublicKey = firstKey,
parentChatRoomId = parentChatRoomId,
subject = "Translators",
)
return DkgRitualViewModel(
chatRoomId = parentChatRoomId,
activeUserPublicKey = me,
dkgSessionId = ceremony.id,
initialDkgRitualUIState = DkgRitualUIState.Loading,
activeIdentityStateFlow = MutableStateFlow(null),
chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room()
},
dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {
override suspend fun roomDerivedBy(dkgSessionId: String): String? =
if (dkgSessionId == ceremony.id) subgroupOf(firstKey) else null
override fun observeSessionById(sessionId: String): Flow<DkgSession?> =
flowOf(ceremony)
override fun observeLatestSessionForChatRoom(
chatRoomId: String,
parentChatRoomId: String?,
): Flow<DkgSession?> = flowOf(ceremony)
override fun observeSigningSessions(
chatRoomId: String
): Flow<List<LocalFrostSigningSession>> = signingSessions
}
)
}
/** The room id a ceremony's key derives, which is what a certificate names. */
private fun subgroupOf(thresholdPublicKey: HexKey): String =
press.mantra.compose.managers.SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
private fun certificateSigning(
id: String,
subgroupChatRoomId: String,
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
) = signing(
id = id,
stage = stage,
event = Event(
id = "ee".repeat(32),
pubKey = me,
createdAt = 0,
kind = SubgroupBirthCertificateEvent.KIND,
tags = SubgroupBirthCertificateEvent.assembleTags(
subgroupChatRoomId = subgroupChatRoomId,
parentChatRoomId = parentChatRoomId,
thresholdPublicKey = firstKey,
adminPublicKeys = listOf(me, peer),
name = "Translators",
),
content = subgroupChatRoomId,
sig = "ff".repeat(64),
)
)
private fun keyStateSigning(
id: String,
dkgSessionId: String,
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
) = signing(
id = id,
stage = stage,
event = Event(
id = "ee".repeat(32),
pubKey = me,
createdAt = 0,
kind = GroupKeyStateEvent.KIND,
tags = GroupKeyStateEvent.assembleTags(
chatRoomId = subgroupOf(firstKey),
dkgSessionId = dkgSessionId,
),
content = firstKey,
sig = "ff".repeat(64),
)
)
private fun signing(
id: String,
event: Event,
stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
) = LocalFrostSigningSession(
session = FrostSigningSession(
id = id,
stage = stage,
chatRoomId = parentChatRoomId,
coordinatorPublicKey = me,
userPublicKey = me,
dkgSessionId = "firstCeremony",
threshold = 2,
participantCount = 2,
signerId = 0,
),
unorderedItems = listOf(
FrostSigningItem(
sessionId = id,
itemIndex = 0,
unsignedEventJson = event.toJson(),
eventId = event.id,
nonceRandom = "55".repeat(32),
)
),
)
private fun room() = LocalChatRoom(
chatRoom = ChatRoom(
id = parentChatRoomId,
userPublicKey = me,
subject = "Ekklesia",
description = null,
mlsGroupState = "state",
),
localParticipants = listOf(me, peer).map { publicKey ->
LocalParticipant(
participant = Participant(
participantPublicKey = publicKey,
chatRoomId = parentChatRoomId,
relayHint = null,
),
profile = null,
)
},
)
}

View File

@@ -111,21 +111,30 @@ class DkgRitualStartOfferJvmTest {
*
* Every flow folding into this state used to read it, build a `copy` and
* assign -- and the session watcher asks the database what this member owes
* in the middle of that. The key-state watcher writing during that pause had
* its change thrown away by the assignment that followed. The same pause in
* the other direction is what put `session` back to null and offered to
* start the ceremony on screen; this is the half of it that can be forced to
* happen rather than raced into.
* in the middle of that. Whoever wrote during that pause had their change
* thrown away by the assignment that followed. The same pause in the other
* direction is what put `session` back to null and offered to start the
* ceremony on screen; this is the half of it that can be forced to happen
* rather than raced into.
*
* The pair used here is the session watcher against the room watcher, which
* are the two that are genuinely concurrent: both are collecting before
* either has written, and neither waits on the other. `roomHasCeremony` is
* the field they fight over, and it is the one that decides the offer.
*/
@Test
fun `a change written during a suspension survives the write that follows it`() = runBlocking {
val session = session(DkgRitualStage.COLLECTING_ROUND_1)
val signingSessions = MutableStateFlow<List<LocalFrostSigningSession>>(emptyList())
val live = session(DkgRitualStage.COLLECTING_ROUND_1)
val abandoned = session(DkgRitualStage.FAILED)
// Held open until the test has had the key-state watcher write. The
// seeding call runs before anything else is collecting, so it is let
// through; the session watcher's call is the one that waits.
val roomSessions = MutableStateFlow<DkgSession?>(live)
// Opened once the test has had the room watcher write. The seeding call
// runs before anything else is collecting, so it is let through; the
// session watcher's call is the one that waits, and `parked` says when
// it is in there.
val gate = CompletableDeferred<Unit>()
val parked = CompletableDeferred<Unit>()
var calls = 0
val viewModel = viewModel(
@@ -133,16 +142,16 @@ class DkgRitualStartOfferJvmTest {
override fun observeLatestSessionForChatRoom(
chatRoomId: String,
parentChatRoomId: String?,
): Flow<DkgSession?> = flowOf(session)
): Flow<DkgSession?> = roomSessions
override fun observeSigningSessions(
chatRoomId: String
): Flow<List<LocalFrostSigningSession>> = signingSessions
override fun observeSessionById(sessionId: String): Flow<DkgSession?> =
flowOf(live)
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? {
calls += 1
if (calls == 1) return null
parked.complete(Unit)
gate.await()
return DkgApprovalStep.ROUND_1
}
@@ -151,33 +160,37 @@ class DkgRitualStartOfferJvmTest {
viewModel.initiate()
withTimeout(5_000) { viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded } }
awaitLoaded(viewModel) { it.roomHasCeremony }
// The watcher is now parked inside `pendingApproval`, holding a copy of
// the state as it was before this line.
signingSessions.value = listOf(keyStateSigningSession())
withTimeout(5_000) {
viewModel.dkgRitualUIState.first {
(it as? DkgRitualUIState.Loaded)?.keyStateSession != null
}
}
// The session watcher is now inside `pendingApproval`, holding -- in the
// shape this used to be written in -- a copy of the state as it was
// before the next two lines.
withTimeout(5_000) { parked.await() }
roomSessions.value = abandoned
awaitLoaded(viewModel) { !it.roomHasCeremony }
gate.complete(Unit)
val settled = withTimeout(5_000) {
viewModel.dkgRitualUIState.first {
(it as? DkgRitualUIState.Loaded)?.pendingApproval == DkgApprovalStep.ROUND_1
}
} as DkgRitualUIState.Loaded
val settled = awaitLoaded(viewModel) {
it.pendingApproval == DkgApprovalStep.ROUND_1
}
assertNotNull(
settled.keyStateSession,
"the session watcher wrote back a copy taken before the key state arrived"
assertFalse(
settled.roomHasCeremony,
"the session watcher wrote back a copy taken before the room watcher's change"
)
assertEquals(session.id, settled.session?.id)
assertEquals(live.id, settled.session?.id)
}
private suspend fun awaitLoaded(
viewModel: DkgRitualViewModel,
until: (DkgRitualUIState.Loaded) -> Boolean,
): DkgRitualUIState.Loaded = withTimeout(5_000) {
viewModel.dkgRitualUIState.first {
(it as? DkgRitualUIState.Loaded)?.let(until) == true
}
} as DkgRitualUIState.Loaded
private fun viewModel(dkgRepository: DkgRepository) = DkgRitualViewModel(
chatRoomId = chatRoomId,
activeUserPublicKey = me,