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