feat(subgroups): the four-rung ladder, the picker, and the list a parent reads off its own signatures

Phase 7 of docs/subgroups.md, and the first commit where a user can make a
subgroup. Four pieces.

**A subgroups section on the group detail screen**, above members, listing
`SubgroupManager.subgroupsOf` -- so it shows a child this device holds no room
for, which is the normal position of a member who is not in the subgroup and of
everybody between the certificate being signed and the room being created. A row
titles itself from the *room* where there is one and only otherwise from the
certificate, because the certificate's name and `p` tags are the founding roster
and a renamed or grown subgroup would otherwise be listed under a name nobody
uses. The supporting line says which of the two absences it is: certified but not
created, or created and you are not in it. Tapping opens the child where this
device has it and the certificate where it does not, since that is the whole of
what is known and it is checkable.

**A parent row on the child's detail screen**, directly under the signing key,
because between them they are what the room *is*: the identity it signs as and
whose child it is. It comes off the verified `GroupKeyState.parentChatRoomId`, so
a member welcomed in after the founding sees nothing there rather than an
unverified guess.

**`SelectSubgroupAdminsScreen`**, where the three things that cannot change later
are settled. The pool is the parent's own members, admins and non-admins alike and
marked rather than filtered -- the point of a subgroup is that it can be run by
people the parent does not let run the parent. The coordinator is shown, ticked
and locked, since they hold a share by construction and leaving them off the list
would make "pick two more" read as a group of two.

Key packages are resolved as the screen opens and a member without one is marked
and unselectable. A key package is one-time-use, so every group a member joins
burns one; `MarmotGroupCreation` would refuse to create the room for a missing one
-- correctly, the address being permanent -- but only after a ChillDKG, a parent
quorum and a child quorum had all completed, each needing every selected admin
present. Finding out at the picker costs nothing and finding out at step 4 costs
three ceremonies. The supporting line names the remedy rather than the diagnosis,
because only its owner can publish another.

The quorum stepper is here and nowhere else, and that is a protocol fact: ChillDKG
hashes the threshold and the host keys into the session identity, so `t` is fixed
the moment the proposal goes out. It is also the one value picked for other
people, and consent survives it -- `t` rides on the proposal, `acceptProposal`
re-checks it against `quorumRange`, and the host-key gate is where each invitee
agrees to the `t`-of-`n` they can now see.

**The ritual screen grows a rung rather than being cloned.** `DkgRitualRoute`
takes an optional `parentChatRoomId`, and with one the ladder is four steps
instead of three: key, certificate, key state, room. A parallel subgroup screen
would have duplicated a progress ladder, a threshold picker, three approval gates
and a key-state rung in order to insert one step, and the copies would drift
within a release.

The certificate rung is watched off the *parent's* signed events and sessions
rather than this room's -- it is signed where the parent's key can sign it, which
is never the ceremony's room. The key-state button stays shut until it is done,
because a subgroup's state carries the certificate and `GroupKeyStateManager`
refuses one without it; opening that session early would throw rather than fail.
And `createAdminGroup` passes the verified parent through to the room, names a
subgroup what the coordinator called it rather than "X (#admins)", and says so.

No new approval UI. The certificate is a `FrostSigningEvents.PROPOSAL` in the
parent's room and the key state one in the ceremony room; `ProposalListScreen` and
`FrostSigningScreen` already show and approve both, on both transports.

Six repository methods carry it: `subgroupsOf`, `parentOf`, `canSign` and
`refuseSubgroup` on `ChatRepository`, and `proposeBirthCertificate`,
`observeBirthCertificate` and `proposeSubgroupKeyState` on `DkgRepository`. The
view models talk to repositories and the managers take the database, which is
where the rest of the app has that line. `refuseSubgroup` returns a refusal when
it cannot compute one, because a guard that fails open is not a guard.

25 new strings in the catalogue in sentence case; 397 common tests, 701 jvm tests,
and `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-08 23:47:29 +02:00
parent 3b00f9f893
commit 6bd59e05fe
17 changed files with 1453 additions and 14 deletions

View File

@@ -365,4 +365,29 @@
<string name="nothing_matched_that_search">Nothing matched that search.</string>
<string name="key_package_published">Key package published</string>
<string name="key_package_rotated">Key package rotated</string>
<string name="subgroups">Subgroups</string>
<string name="add_subgroup">Add subgroup</string>
<string name="no_subgroups_have_been_made_by_this_group">No subgroups have been made by this group.</string>
<string name="certified_not_yet_created">Certified, not created yet</string>
<string name="created_you_are_not_a_member">Created, you aren't a member</string>
<string name="a_subgroup">A subgroup</string>
<string name="parent_group">Parent group</string>
<string name="the_group_this_one_is_a_subgroup_of">The group this one is a subgroup of</string>
<string name="run_by_members">Run by %1$s members</string>
<string name="new_subgroup">New subgroup</string>
<string name="what_is_the_subgroup_called">What is the subgroup called?</string>
<string name="who_runs_the_subgroup">Who runs the subgroup?</string>
<string name="a_subgroup_needs_at_least_three_admins">A subgroup needs at least three admins, you included, so pick at least two more people.</string>
<string name="anyone_in_this_group_can_run_a_subgroup">Anyone in this group can run a subgroup, whether or not they administer this one.</string>
<string name="has_no_key_package_yet">Has no key package yet</string>
<string name="admin_of_this_group">Admin of this group</string>
<string name="you_coordinate_this_subgroup">You coordinate this subgroup</string>
<string name="start_the_key_ceremony">Start the key ceremony</string>
<string name="checking_who_can_be_added">Checking who can be added…</string>
<string name="the_parent_group_has_certified_this_subgroup">The parent group has certified this subgroup</string>
<string name="the_parent_group_could_not_certify_this_subgroup">The parent group could not certify this subgroup</string>
<string name="the_parent_group_is_certifying_this_subgroup">The parent group is certifying this subgroup — %1$s of %2$s admins have to sign</string>
<string name="before_the_subgroup_exists_its_parent_signs">Before the subgroup exists, its parent signs for it. That signature is what lets anyone check where this group came from.</string>
<string name="ask_the_parent_group_to_certify">Ask the parent group to certify this subgroup</string>
<string name="create_the_subgroup">Create the subgroup</string>
</resources>

View File

@@ -5,8 +5,10 @@ import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.managers.ChronicleManager
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.managers.MarmotGroupCreation
import press.mantra.compose.managers.SubgroupManager
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.GiftWrapPayload
@@ -101,6 +103,50 @@ class DatabaseChatRepository(
null
}
override suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup> = try {
SubgroupManager.subgroupsOf(database, chatRoomId)
} catch (e: Throwable) {
// A room that shows no subgroups is wrong and survivable; one that will
// not open because reading them threw is neither.
logger.e("Error reading the subgroups of $chatRoomId", e)
emptyList()
}
override suspend fun parentOf(chatRoomId: String): HexKey? = try {
SubgroupManager.parentOf(database, chatRoomId)
} catch (e: Throwable) {
logger.e("Error reading the parent of $chatRoomId", e)
null
}
override suspend fun canSign(chatRoomId: String): Boolean = try {
FrostSigningManager.canSign(database, chatRoomId)
} catch (e: Throwable) {
logger.e("Error reading whether $chatRoomId can sign", e)
false
}
override suspend fun refuseSubgroup(
parentChatRoomId: String,
adminPublicKeys: Set<HexKey>,
coordinatorPublicKey: HexKey,
): String? = try {
val parentRoom = database.chatRoomDao().findChatRoomById(parentChatRoomId)
?: return "Couldn't load this group."
SubgroupManager.refuseCeremonyRoom(
database = database,
parentRoom = parentRoom,
adminPublicKeys = adminPublicKeys,
coordinatorPublicKey = coordinatorPublicKey,
)
} catch (e: Throwable) {
// A refusal that cannot be computed has to refuse. The alternative is
// opening a ceremony whose guards never ran.
logger.e("Error checking whether $parentChatRoomId can take a subgroup", e)
"Couldn't check this subgroup. Please try again."
}
override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List<Participant> {
return database.participantDao().findParticipantByPublicKey(publicKey)
}

View File

@@ -5,13 +5,16 @@ import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.managers.SubgroupManager
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
import press.mantra.compose.repository.DkgRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -108,6 +111,78 @@ class DatabaseDkgRepository(
null
}
override suspend fun proposeBirthCertificate(
parentChatRoomId: String,
userPublicKey: HexKey,
session: DkgSession,
adminPublicKeys: List<HexKey>,
name: String,
): FrostSigningSession? = try {
val parentRoom = database.chatRoomDao().findChatRoomById(parentChatRoomId)
?: throw IllegalStateException("No parent room $parentChatRoomId to certify from")
SubgroupManager.proposeBirthCertificate(
database = database,
parentRoom = parentRoom,
userPublicKey = userPublicKey,
key = session,
adminPublicKeys = adminPublicKeys,
name = name,
)
} catch (e: Throwable) {
// A device with no share of the parent's key cannot open this session,
// and a ceremony with no key yet is a caller that got ahead of itself.
// Neither is worth failing anything over: nothing was published, and the
// screen reports it by leaving the rung unfinished with the button there.
logger.e("Error proposing a birth certificate in $parentChatRoomId", e)
null
}
override fun observeBirthCertificate(
ceremonyChatRoomId: String,
parentChatRoomId: String,
): Flow<GroupSignedEvent?> =
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()
.getLatestSessionForChatRoom(ceremonyChatRoomId)
?.thresholdPublicKey
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
?: return@map null
signedEvents
.filter {
SubgroupBirthCertificateEvent.certifies(
event = it.toEvent(),
subgroupChatRoomId = subgroupChatRoomId,
parentChatRoomId = parentChatRoomId,
)
}
.maxByOrNull { it.createdAt }
}
override suspend fun proposeSubgroupKeyState(
ceremonyRoom: LocalChatRoom,
userPublicKey: HexKey,
session: DkgSession,
parentChatRoomId: HexKey,
): FrostSigningSession? = try {
SubgroupManager.proposeSubgroupKeyState(
database = database,
ceremonyRoom = ceremonyRoom,
userPublicKey = userPublicKey,
key = session,
parentChatRoomId = parentChatRoomId,
)
} catch (e: Throwable) {
logger.e("Error proposing the subgroup key state from ${ceremonyRoom.chatRoom.id}", e)
null
}
override suspend fun approve(
localChatRoom: LocalChatRoom,
sessionId: String,

View File

@@ -8,6 +8,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import press.mantra.compose.database.model.MarmotKeyPackage
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.managers.MarmotGroupCreation
import press.mantra.compose.managers.SubgroupManager
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.intermdiate.LocalChatMessage
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
@@ -58,6 +59,50 @@ interface ChatRepository {
*/
suspend fun signedGroupKeyStateEvent(chatRoomId: String): GroupSignedEvent?
/**
* Every subgroup this room has certified, as far as this device knows.
*
* Read off the parent's own signed certificates rather than off a table, so
* it includes children whose room this device does not have -- which is the
* normal position of a member who is not in the subgroup. See
* `SubgroupManager.subgroupsOf`.
*/
suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup>
/**
* The group this room is a subgroup of, verified, or null if it is nobody's
* child.
*
* Off the room's `GroupKeyState`, which only carries a parent whose
* certificate was checked. A member welcomed in after the founding holds no
* state and gets null here even when the room really is a subgroup.
*/
suspend fun parentOf(chatRoomId: String): HexKey?
/**
* Whether this device holds a share of the group's key, and so could take
* part in signing for it.
*
* Read by the UI so nothing is offered that would throw:
* `FrostSigningManager.proposeSigningBatch` refuses outright for a device
* with no share, and a button that throws is not a button.
*/
suspend fun canSign(chatRoomId: String): Boolean
/**
* Why these members cannot hold a subgroup ceremony together, or null when
* they can.
*
* Read by the picker so a subgroup that could only fail is never offered, and
* again on confirm, because a screen not drawing something is not a guard.
* See `SubgroupManager.refuseCeremonyRoom`.
*/
suspend fun refuseSubgroup(
parentChatRoomId: String,
adminPublicKeys: Set<HexKey>,
coordinatorPublicKey: HexKey,
): String?
suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List<Participant>
suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List<LocalChatMessage>
@@ -210,6 +255,19 @@ interface ChatRepository {
chatRoomId: String
): GroupSignedEvent? = null
override suspend fun subgroupsOf(chatRoomId: String): List<SubgroupManager.Subgroup> =
emptyList()
override suspend fun parentOf(chatRoomId: String): HexKey? = null
override suspend fun canSign(chatRoomId: String): Boolean = false
override suspend fun refuseSubgroup(
parentChatRoomId: String,
adminPublicKeys: Set<HexKey>,
coordinatorPublicKey: HexKey,
): String? = "No database"
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> {
return flow { }
}

View File

@@ -4,6 +4,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
import press.mantra.compose.database.model.types.DkgApprovalStep
@@ -96,6 +97,47 @@ interface DkgRepository {
/** Files the state the group signed for a room that now exists. */
suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState?
/**
* Asks the parent's quorum to certify the subgroup this ceremony produced.
*
* Runs in the **parent's** room, not the ceremony's, so what comes back is
* signed by the parent's key and authored by the parent's own room id -- the
* one thing a lineage can be checked against. See
* `SubgroupManager.proposeBirthCertificate`.
*/
suspend fun proposeBirthCertificate(
parentChatRoomId: String,
userPublicKey: HexKey,
session: DkgSession,
adminPublicKeys: List<HexKey>,
name: String,
): FrostSigningSession?
/**
* Whether the parent has certified the room this ceremony's key derives.
*
* Answerable before that room exists, which is the point: it is what gates
* the key state, which is itself signed before the room is created.
*/
fun observeBirthCertificate(
ceremonyChatRoomId: String,
parentChatRoomId: String,
): Flow<GroupSignedEvent?>
/**
* Asks the subgroup's own quorum to sign its key state, carrying the
* certificate.
*
* Refuses without one, because every device that receives the state runs the
* same check and drops it -- see `GroupKeyStateManager.stateFrom`.
*/
suspend fun proposeSubgroupKeyState(
ceremonyRoom: LocalChatRoom,
userPublicKey: HexKey,
session: DkgSession,
parentChatRoomId: HexKey,
): FrostSigningSession?
companion object {
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> = flowOf(null)
@@ -135,6 +177,26 @@ interface DkgRepository {
flowOf(null)
override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null
override suspend fun proposeBirthCertificate(
parentChatRoomId: String,
userPublicKey: HexKey,
session: DkgSession,
adminPublicKeys: List<HexKey>,
name: String,
): FrostSigningSession? = null
override fun observeBirthCertificate(
ceremonyChatRoomId: String,
parentChatRoomId: String,
): Flow<GroupSignedEvent?> = flowOf(null)
override suspend fun proposeSubgroupKeyState(
ceremonyRoom: LocalChatRoom,
userPublicKey: HexKey,
session: DkgSession,
parentChatRoomId: HexKey,
): FrostSigningSession? = null
}
}
}

View File

@@ -85,6 +85,18 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.theme.spacing
import mantra.composeapp.generated.resources.run_by_members
import mantra.composeapp.generated.resources.parent_group
import mantra.composeapp.generated.resources.a_subgroup
import mantra.composeapp.generated.resources.created_you_are_not_a_member
import mantra.composeapp.generated.resources.certified_not_yet_created
import mantra.composeapp.generated.resources.no_subgroups_have_been_made_by_this_group
import mantra.composeapp.generated.resources.add_subgroup
import mantra.composeapp.generated.resources.subgroups
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.managers.SubgroupManager
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.add_artifact_to_library
@@ -237,6 +249,57 @@ fun ChatRoomDetailScreen(
}
}
// Directly under the signing key, because between them
// they are what the room *is*: the identity it signs as
// and whose child it is. Absent on a group that is
// nobody's child, and absent -- rather than guessed --
// on a subgroup this device holds no key state for,
// since an unverified lineage is not one worth showing.
chatRoomDetailUIState.parentChatRoomId?.let { parentChatRoomId ->
item {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
onNavigateToRoute.invoke(
ChatRoomDetailRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = parentChatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.AccountTree,
contentDescription = Decorative
)
},
trailingContent = {
Icon(
Icons.Default.ChevronRight,
contentDescription = Decorative
)
},
headlineContent = {
Text(text = stringResource(Res.string.parent_group))
},
supportingContent = {
Text(
text = parentChatRoomId
.take(16)
.inComparableGroups(),
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis
)
}
)
}
}
}
item {
chatRoomDetailUIState.localChatRoom.chatRoom.description?.let {
Text(
@@ -444,6 +507,82 @@ fun ChatRoomDetailScreen(
HorizontalDivider()
}
item {
Text(
text = stringResource(Res.string.subgroups),
style = MaterialTheme.typography.labelMedium
)
}
if (chatRoomDetailUIState.subgroups.isEmpty()) {
item {
Text(stringResource(Res.string.no_subgroups_have_been_made_by_this_group))
}
} else {
items(
items = chatRoomDetailUIState.subgroups,
key = { subgroup -> subgroup.chatRoomId }
) { subgroup ->
SubgroupCard(
subgroup = subgroup,
onOpen = {
// Into the child where this device has
// it; at the certificate where it does
// not, since that is the whole of what
// is known and it is checkable.
onNavigateToRoute.invoke(
subgroup.room?.let {
ChatRoomDetailRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = it.chatRoom.id,
relayHint = relayHint
)
} ?: NostrEventDetailRoute(
activeUserPublicKey = activeUserPublicKey,
nostrEventId = subgroup.certificate.id
)
)
}
)
}
}
// Offered only where it can work: this device holds a
// share of the group's key, so it can open the
// certificate session, and the room is a Marmot one, so
// there is a group with admins to be a parent. Hidden
// rather than disabled, the way the shared key entry is.
if (chatRoomDetailUIState.canAddSubgroup) {
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
SelectSubgroupAdminsRoute(
activeUserPublicKey = activeUserPublicKey,
parentChatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.AccountTree,
contentDescription = Decorative
)
Spacer(
modifier = Modifier.width(MaterialTheme.spacing.space125)
)
Text(stringResource(Res.string.add_subgroup))
}
}
}
item {
HorizontalDivider()
}
item {
Text(
text = stringResource(Res.string.members_2),
@@ -911,6 +1050,56 @@ internal fun GroupKeyStateSheetContent(
* signed event, which is not grouped -- so the display can be shaped for reading
* without a paste losing the value.
*/
/**
* One subgroup, as the parent's record and this device's rooms together have it.
*
* **The title comes from the room where there is one, and only otherwise from the
* certificate.** The certificate's name and `p` tags are what the parent approved
* at founding, frozen: the child may since have been renamed or have gained
* members, and none of that reaches a signature already made. So where this
* device holds the room, the room is the live answer; where it does not, the
* certificate is all there is and the supporting line says which of the two
* absences this is.
*/
@Composable
private fun SubgroupCard(
subgroup: SubgroupManager.Subgroup,
onOpen: () -> Unit
) {
Card(onClick = onOpen) {
ListItem(
leadingContent = {
Icon(Icons.Default.AccountTree, contentDescription = Decorative)
},
trailingContent = {
Icon(Icons.Default.ChevronRight, contentDescription = Decorative)
},
headlineContent = {
Text(
text = subgroup.room?.chatRoom?.subject
?: subgroup.name
?: stringResource(Res.string.a_subgroup)
)
},
supportingContent = {
Text(
text = when {
subgroup.isJoined -> stringResource(
Res.string.run_by_members,
subgroup.adminPublicKeys.size.toString()
)
// The room does not exist anywhere yet: the parent has
// certified it and nobody has created it. Between steps 2
// and 4 of docs/subgroups.md this is every member's view.
subgroup.keyState == null -> stringResource(Res.string.certified_not_yet_created)
else -> stringResource(Res.string.created_you_are_not_a_member)
}
)
}
)
}
}
private fun String.inComparableGroups(): String = chunked(8).joinToString(" ")
/** One labelled value of a key state, whole and comparable. */

View File

@@ -81,6 +81,13 @@ import press.mantra.compose.ui.composable.widgets.Decorative
import mantra.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.create_the_admins_group
import androidx.compose.material.icons.filled.AccountTree
import mantra.composeapp.generated.resources.create_the_subgroup
import mantra.composeapp.generated.resources.ask_the_parent_group_to_certify
import mantra.composeapp.generated.resources.before_the_subgroup_exists_its_parent_signs
import mantra.composeapp.generated.resources.the_parent_group_is_certifying_this_subgroup
import mantra.composeapp.generated.resources.the_parent_group_could_not_certify_this_subgroup
import mantra.composeapp.generated.resources.the_parent_group_has_certified_this_subgroup
import mantra.composeapp.generated.resources.how_many_members_will_it_take_to_sign
import mantra.composeapp.generated.resources.key
import mantra.composeapp.generated.resources.members
@@ -125,6 +132,11 @@ import press.mantra.compose.ui.theme.ConformancePreviews
fun DkgRitualScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
/**
* Set when this ceremony is making a subgroup, which grows the ladder by one
* rung: the parent's birth certificate, between the key and the key state.
*/
parentChatRoomId: String? = null,
initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
chatRepository: ChatRepository,
@@ -135,6 +147,7 @@ fun DkgRitualScreen(
factory = DkgRitualViewModel.factory(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
parentChatRoomId = parentChatRoomId,
initialDkgRitualUIState = initialDkgRitualUIState,
activeWalletStateFlow = activeWalletStateFlow,
chatRepository = chatRepository,
@@ -294,6 +307,10 @@ fun DkgRitualScreen(
adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn,
keyStateSession = dkgRitualUIState.keyStateSession,
isKeyStateSigned = dkgRitualUIState.isKeyStateSigned,
parentChatRoomId = dkgRitualUIState.parentChatRoomId,
isCertified = dkgRitualUIState.isCertified,
certificateSession = dkgRitualUIState.certificateSession,
onProposeBirthCertificate = dkgRitualViewModel::proposeBirthCertificate,
onProposeKeyState = dkgRitualViewModel::proposeKeyState,
onCreateAdminGroup = {
dkgRitualViewModel.createAdminGroup(onNavigateToRoute)
@@ -343,6 +360,10 @@ private fun RitualProgress(
adminGroupBlockedOn: List<String>,
keyStateSession: FrostSigningSession?,
isKeyStateSigned: Boolean,
parentChatRoomId: String?,
isCertified: Boolean,
certificateSession: FrostSigningSession?,
onProposeBirthCertificate: () -> Unit,
onProposeKeyState: () -> Unit,
onCreateAdminGroup: () -> Unit,
) {
@@ -500,6 +521,71 @@ private fun RitualProgress(
// Shown to every member, not only the coordinator, because every
// member has to sign it -- the request itself is a line in this
// chat, which is where they answer it.
// A subgroup has one more thing to settle than a group does, and
// it comes first: the parent's signature over this room's id. The
// key state carries that certificate, so it cannot be proposed
// until this rung is done -- which is why the order on screen is
// the order the steps actually happen in rather than a layout
// choice.
if (parentChatRoomId != null) {
val certificateFailed = certificateSession?.stage == FrostSigningStage.FAILED
when {
isCertified -> Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.CheckCircle, contentDescription = Decorative)
Text(
text = stringResource(Res.string.the_parent_group_has_certified_this_subgroup),
style = MaterialTheme.typography.labelMedium
)
}
certificateFailed -> Text(
text = stringResource(Res.string.the_parent_group_could_not_certify_this_subgroup),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error
)
certificateSession != null -> Text(
text = stringResource(
Res.string.the_parent_group_is_certifying_this_subgroup,
certificateSession.threshold,
certificateSession.participantCount
),
style = MaterialTheme.typography.labelMedium
)
else -> Text(
text = stringResource(Res.string.before_the_subgroup_exists_its_parent_signs),
style = MaterialTheme.typography.labelMedium
)
}
// Offered to whoever holds a share of the parent's key, which
// the coordinator does by construction -- being a parent admin
// is how they got here. The other subgroup admins answer it in
// the parent's room, where the proposal lands, so there is
// nothing for them to press here.
if (session.isCoordinator() && !isCertified) {
Button(
onClick = { onProposeBirthCertificate() },
enabled = !isActionPending &&
(certificateSession == null || certificateFailed),
modifier = Modifier.fillMaxWidth()
) {
if (isActionPending) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Icon(Icons.Default.AccountTree, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
Text(text = stringResource(Res.string.ask_the_parent_group_to_certify))
}
}
}
}
val keyStateFailed = keyStateSession?.stage == FrostSigningStage.FAILED
when {
@@ -567,7 +653,13 @@ private fun RitualProgress(
} else {
Icon(Icons.Default.Groups, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
Text(text = stringResource(Res.string.create_the_admins_group))
Text(
text = if (parentChatRoomId != null) {
stringResource(Res.string.create_the_subgroup)
} else {
stringResource(Res.string.create_the_admins_group)
}
)
}
}
} else {
@@ -579,7 +671,13 @@ private fun RitualProgress(
// this is the safe retry by construction.
Button(
onClick = { onProposeKeyState() },
enabled = !isActionPending && (keyStateSession == null || keyStateFailed),
// A subgroup's key state carries the certificate and
// is refused without one, so this stays shut until the
// rung above it is done rather than opening a session
// that would throw.
enabled = !isActionPending &&
(keyStateSession == null || keyStateFailed) &&
(parentChatRoomId == null || isCertified),
modifier = Modifier.fillMaxWidth()
) {
if (isActionPending) {

View File

@@ -0,0 +1,334 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.a_subgroup_needs_at_least_three_admins
import mantra.composeapp.generated.resources.admin_of_this_group
import mantra.composeapp.generated.resources.anyone_in_this_group_can_run_a_subgroup
import mantra.composeapp.generated.resources.has_no_key_package_yet
import mantra.composeapp.generated.resources.how_many_admins_have_to_approve_a_change
import mantra.composeapp.generated.resources.new_subgroup
import mantra.composeapp.generated.resources.of
import mantra.composeapp.generated.resources.start_the_key_ceremony
import mantra.composeapp.generated.resources.what_is_the_subgroup_called
import mantra.composeapp.generated.resources.who_runs_the_subgroup
import mantra.composeapp.generated.resources.you_coordinate_this_subgroup
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.ErrorState
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
import press.mantra.compose.ui.theme.ConformancePreviews
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.view.model.SelectSubgroupAdminsViewModel
import press.mantra.compose.ui.view.state.SelectSubgroupAdminsUIState
/**
* Picks who runs a subgroup, what it is called, and on what quorum.
*
* The three things that cannot be changed later, gathered before anything is
* published -- see `SelectSubgroupAdminsViewModel` for why the threshold in
* particular has nowhere else to live.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun SelectSubgroupAdminsScreen(
activeUserPublicKey: HexKey,
parentChatRoomId: String,
initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
chatRepository: ChatRepository,
dkgRepository: DkgRepository,
onNavigateToRoute: (Route) -> Unit
) {
val viewModel: SelectSubgroupAdminsViewModel = viewModel(
factory = SelectSubgroupAdminsViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
parentChatRoomId = parentChatRoomId,
initialUIState = initialUIState,
activeWalletStateFlow = activeWalletStateFlow,
chatRepository = chatRepository,
dkgRepository = dkgRepository
)
)
LaunchedEffect(parentChatRoomId) { viewModel.initiate() }
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
ScreenStateTransition(viewModel.uiState) { uiState ->
when (val state = uiState) {
is SelectSubgroupAdminsUIState.Error -> ErrorState(
message = state.message,
// Retrying reloads the group, which is the thing that failed.
onRetry = { viewModel.initiate() }
)
SelectSubgroupAdminsUIState.Loading -> LoadingDataIndicator()
is SelectSubgroupAdminsUIState.Loaded -> Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(title = { Text(stringResource(Res.string.new_subgroup)) })
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
scope.launch {
// The refusal is recomputed rather than cached on
// the button: the key-package answer can go stale
// while the screen is open, and confirm checks
// again anyway.
val refusal = viewModel.refusal()
if (refusal != null) {
snackbarHostState.showSnackbar(refusal)
} else {
viewModel.confirm(onNavigateToRoute)
}
}
},
icon = { Icon(Icons.Default.AccountTree, contentDescription = Decorative) },
text = { Text(stringResource(Res.string.start_the_key_ceremony)) }
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.padding(MaterialTheme.spacing.space250),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
) {
item {
OutlinedTextField(
value = viewModel.name.value,
onValueChange = viewModel::setName,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringResource(Res.string.what_is_the_subgroup_called)) }
)
}
item {
Text(
text = stringResource(Res.string.who_runs_the_subgroup),
style = MaterialTheme.typography.labelMedium
)
}
item {
// Stated rather than discovered by tapping a disabled
// button. Three is the floor a quorum stops being a check
// below, and the coordinator is one of the three whether
// or not anybody ticks them.
Text(
text = stringResource(Res.string.a_subgroup_needs_at_least_three_admins),
style = MaterialTheme.typography.bodySmall
)
}
item {
Text(
text = stringResource(Res.string.anyone_in_this_group_can_run_a_subgroup),
style = MaterialTheme.typography.bodySmall
)
}
// The coordinator, shown and not offered. They are a ChillDKG
// participant by construction and hold a share whether or not
// anybody ticked them, so leaving them off the list entirely
// would make "pick two more" read as a group of two.
item {
Card(modifier = Modifier.fillMaxWidth()) {
ListItem(
leadingContent = {
ProfileAvatar(
publicKey = activeUserPublicKey,
profile = null
)
},
headlineContent = {
Text(stringResource(Res.string.you_coordinate_this_subgroup))
},
trailingContent = {
Checkbox(checked = true, enabled = false, onCheckedChange = null)
}
)
}
}
items(
items = state.candidates,
key = { it.participant.participantPublicKey }
) { candidate ->
val publicKey = candidate.participant.participantPublicKey
val isUnreachable = publicKey in state.withoutKeyPackage
Card(
onClick = { viewModel.toggleMember(publicKey) },
enabled = !isUnreachable
) {
ListItem(
leadingContent = {
ProfileAvatar(
publicKey = publicKey,
profile = candidate.profile
)
},
headlineContent = {
Text(
text = candidate.profile?.humanReadableNameOrPubkey()
?: publicKey,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis
)
},
supportingContent = {
// The remedy, not the diagnosis. A key package
// is one-time-use and only its owner can
// publish another, so the useful thing to say
// is whose door to knock on.
if (isUnreachable) {
Text(stringResource(Res.string.has_no_key_package_yet))
} else if (candidate.participant.adminAt != null) {
Text(stringResource(Res.string.admin_of_this_group))
}
},
trailingContent = {
Checkbox(
checked = viewModel.isSelected(publicKey),
enabled = !isUnreachable,
onCheckedChange = { viewModel.toggleMember(publicKey) }
)
}
)
}
}
item {
QuorumStepper(
quorum = viewModel.threshold.value,
adminCount = viewModel.adminCount(),
quorumRange = viewModel.quorumRange(),
onQuorumChange = viewModel::setThreshold
)
}
}
}
}
}
}
/**
* How many of the subgroup's admins have to sign for it.
*
* Stepped rather than typed, like the one on the group-creation screen: the range
* is small and both ends are bounded. Unlike that one it cannot be revisited --
* ChillDKG hashes the threshold into the session identity, so this is the only
* moment it can be chosen at all.
*/
@Composable
private fun QuorumStepper(
quorum: Int,
adminCount: Int,
quorumRange: IntRange,
onQuorumChange: (Int) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.space50),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)
) {
Text(
text = stringResource(Res.string.how_many_admins_have_to_approve_a_change),
style = MaterialTheme.typography.titleSmall
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200),
verticalAlignment = Alignment.CenterVertically
) {
FilledIconButton(
onClick = { onQuorumChange(quorum - 1) },
enabled = quorum > quorumRange.first
) {
Icon(Icons.Default.Remove, contentDescription = "Fewer approvals")
}
Text(
text = stringResource(Res.string.of, quorum, adminCount),
style = MaterialTheme.typography.titleMedium
)
FilledIconButton(
onClick = { onQuorumChange(quorum + 1) },
enabled = quorum < quorumRange.last
) {
Icon(Icons.Default.Add, contentDescription = "More approvals")
}
}
}
}
@ConformancePreviews
@Composable
private fun SelectSubgroupAdminsScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
SelectSubgroupAdminsScreen(
activeUserPublicKey = "a".repeat(64),
parentChatRoomId = "b".repeat(64),
activeWalletStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null),
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY,
onNavigateToRoute = {}
)
}
}
}

View File

@@ -87,6 +87,8 @@ import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToC
import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchRoute
import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomMembersRoute
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.SelectSubgroupAdminsScreen
import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute
import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute
import press.mantra.compose.ui.composable.navigation.routes.SignInRoute
@@ -530,12 +532,27 @@ fun MantraNavHost(
}
)
}
composable<SelectSubgroupAdminsRoute> { backStackEntry ->
val route = backStackEntry.toRoute<SelectSubgroupAdminsRoute>()
SelectSubgroupAdminsScreen(
activeUserPublicKey = route.activeUserPublicKey,
parentChatRoomId = route.parentChatRoomId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository,
onNavigateToRoute = { nextRoute ->
navController.navigate(route = nextRoute)
}
)
}
composable<DkgRitualRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DkgRitualRoute>()
DkgRitualScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
parentChatRoomId = route.parentChatRoomId,
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
chatRepository = databaseChatRepository,
dkgRepository = databaseDkgRepository,

View File

@@ -2,9 +2,18 @@ package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/** The shared-key ceremony for one NIP-17 group. */
/**
* The shared-key ceremony for one NIP-17 group.
*
* [parentChatRoomId] is set when the ceremony is making a subgroup, and turns the
* screen from a three-step ladder into a four-step one -- the extra rung being the
* parent's birth certificate. It is a claim rather than evidence, in the route as
* on the wire: what makes a lineage real is the certificate the parent's quorum
* signs, two steps later. See `docs/subgroups.md`.
*/
@Serializable
data class DkgRitualRoute(
val activeUserPublicKey: String,
val chatRoomId: String
val chatRoomId: String,
val parentChatRoomId: String? = null
): Route()

View File

@@ -0,0 +1,20 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* First step of making a subgroup: who runs it, what it is called, and on what
* quorum.
*
* All three have to be settled before anything is published. ChillDKG hashes the
* threshold and the host keys into the session identity, so `t` is fixed the
* moment the proposal goes out -- there is no later screen where it could be
* changed, and a group that disagrees about it gets no key at all rather than a
* weak one.
*/
@Serializable
data class SelectSubgroupAdminsRoute(
val activeUserPublicKey: String,
val parentChatRoomId: String,
val relayHint: String? = null
): Route()

View File

@@ -80,6 +80,10 @@ class ChatRoomDetailViewModel(
dialects = mantraRepository.getDialects(chatRoomId),
groupKeyState = chatRepository.groupKeyState(chatRoomId),
signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId),
subgroups = chatRepository.subgroupsOf(chatRoomId),
parentChatRoomId = chatRepository.parentOf(chatRoomId),
canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null &&
chatRepository.canSign(chatRoomId),
)
}
}

View File

@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.Dispatchers.Main
import press.mantra.compose.nostr.dkg.DkgRitualEvents
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
@@ -49,6 +50,11 @@ import kotlinx.coroutines.launch
class DkgRitualViewModel(
val chatRoomId: String,
val activeUserPublicKey: HexKey,
/**
* The group this ceremony is making a subgroup of, or null for an ordinary
* one. Off the route, which got it off the proposal -- a claim either way.
*/
val parentChatRoomId: HexKey? = null,
initialDkgRitualUIState: DkgRitualUIState,
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
val chatRepository: ChatRepository,
@@ -118,7 +124,10 @@ class DkgRitualViewModel(
return@launch
}
dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
dkgRitualUIState = DkgRitualUIState.Loaded(
localChatRoom = localChatRoom,
parentChatRoomId = parentChatRoomId
)
threshold.value = ChatRoomType
.defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
.coerceIn(quorumRange())
@@ -127,7 +136,10 @@ class DkgRitualViewModel(
dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { session ->
val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
?: DkgRitualUIState.Loaded(
localChatRoom = localChatRoom,
parentChatRoomId = parentChatRoomId
)
dkgRitualUIState = loaded.copy(
session = session,
@@ -206,6 +218,37 @@ class DkgRitualViewModel(
dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null)
}
}
// Only for a subgroup, and off the *parent's* signed events rather
// than this room's -- the certificate is made where the parent's key
// can sign it, which is never the ceremony's room.
parentChatRoomId?.let { parent ->
launch {
dkgRepository.observeBirthCertificate(chatRoomId, parent)
.collect { certificate ->
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded
?: return@collect
dkgRitualUIState = loaded.copy(isCertified = certificate != null)
}
}
launch {
dkgRepository.observeSigningSessions(parent).collect { sessions ->
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
dkgRitualUIState = loaded.copy(
certificateSession = sessions.firstOrNull { local ->
local.items.any { item ->
runCatching {
Event.fromJson(item.unsignedEventJson).kind
}.getOrNull() == SubgroupBirthCertificateEvent.KIND
}
}?.session
)
}
}
}
}
}
@@ -236,11 +279,24 @@ class DkgRitualViewModel(
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val signing = dkgRepository.proposeGroupKeyState(
localChatRoom = loaded.localChatRoom,
userPublicKey = activeUserPublicKey,
session = session
)
// A subgroup's state carries the parent's certificate, and refuses
// to be proposed without one -- every device that receives it runs
// the same check and drops it, so proposing one this device would not
// believe spends a quorum's attention on nothing.
val signing = if (parentChatRoomId != null) {
dkgRepository.proposeSubgroupKeyState(
ceremonyRoom = loaded.localChatRoom,
userPublicKey = activeUserPublicKey,
session = session,
parentChatRoomId = parentChatRoomId
)
} else {
dkgRepository.proposeGroupKeyState(
localChatRoom = loaded.localChatRoom,
userPublicKey = activeUserPublicKey,
session = session
)
}
isActionPending.value = false
@@ -255,6 +311,58 @@ class DkgRitualViewModel(
}
}
/**
* Asks the parent's quorum to certify the subgroup this ceremony produced.
*
* Step 2 of four, and the only one that runs somewhere else: the certificate
* is signed in the *parent's* room, because it is the parent's signature.
* Offered to whoever holds a share of the parent's key, which the coordinator
* does by construction -- they are a parent admin, that is how they got here.
*
* Nothing waits for the answer. The session runs on arriving messages like
* every other, and [observeKeyState] follows it.
*/
fun proposeBirthCertificate() {
if (isActionPending.value) return
val parent = parentChatRoomId ?: return
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
val session = loaded.session ?: return
if (session.thresholdPublicKey == null) return
// Nothing to ask for, and nobody to ask twice. A failed session is the
// one case a second proposal is right, and it has to be a new session
// rather than that one resumed.
if (loaded.isCertified) return
loaded.certificateSession?.let { if (it.stage != FrostSigningStage.FAILED) return }
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val signing = dkgRepository.proposeBirthCertificate(
parentChatRoomId = parent,
userPublicKey = activeUserPublicKey,
session = session,
adminPublicKeys = ChillDkgRitualManager
.memberPublicKeys(loaded.localChatRoom)
.toList(),
// The ceremony room was created with the name the coordinator
// typed, and the proposal carried it to everyone else -- so this
// is the same name every admin has been looking at.
name = loaded.localChatRoom.chatRoom.subject ?: "Subgroup"
)
isActionPending.value = false
if (signing == null) {
logger.e("Failed to propose a birth certificate in $parent")
dkgRitualUIState = DkgRitualUIState.Error(
"Couldn't ask the group to certify this subgroup. Please try again."
)
}
}
}
/** Opens a ritual, making this device its coordinator, and publishes the proposal. */
fun startRitual() {
if (isActionPending.value) return
@@ -367,7 +475,15 @@ class DkgRitualViewModel(
// admins: the whole point of the room is that the members who hold shares of
// the key can all act in it.
val members = ChillDkgRitualManager.memberPublicKeys(loaded.localChatRoom)
val name = "${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)"
// A subgroup is called what the coordinator called it; an admin room is
// called after the group it administers, because it has no name of its
// own to be given.
val name = if (parentChatRoomId != null) {
loaded.localChatRoom.chatRoom.subject ?: "Subgroup"
} else {
"${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)"
}
isActionPending.value = true
dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList())
@@ -376,10 +492,18 @@ class DkgRitualViewModel(
val outcome = chatRepository.createMarmotGroup(
groupId = groupId,
name = name,
purpose = "Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}.",
purpose = if (parentChatRoomId != null) {
"A subgroup of the group that certified it."
} else {
"Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}."
},
adminPublicKeys = members,
userPublicKey = activeUserPublicKey,
keyPair = keyPair
keyPair = keyPair,
// Verified by the time it is written: the key state this room was
// gated on carries the certificate, and `stateFrom` refused it
// otherwise.
parentChatRoomId = parentChatRoomId
)
isActionPending.value = false
@@ -442,6 +566,7 @@ class DkgRitualViewModel(
fun factory(
chatRoomId: String,
activeUserPublicKey: HexKey,
parentChatRoomId: HexKey? = null,
initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
chatRepository: ChatRepository,
@@ -451,6 +576,7 @@ class DkgRitualViewModel(
DkgRitualViewModel(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
parentChatRoomId = parentChatRoomId,
initialDkgRitualUIState = initialDkgRitualUIState,
activeWalletStateFlow = activeWalletStateFlow,
chatRepository = chatRepository,

View File

@@ -0,0 +1,285 @@
package press.mantra.compose.ui.view.model
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import fr.acinq.phoenix.managers.nostrPrivateKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.managers.MarmotGroupCreation
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.view.state.SelectSubgroupAdminsUIState
/**
* Who runs a subgroup, what it is called, and on what quorum.
*
* All three are settled here and nowhere else. The name and the admin set are
* carried into the birth certificate, so they are what the parent's admins will
* be asked to approve; the threshold cannot move afterwards at all, because
* ChillDKG hashes it and the host keys into the session identity, so `t` is fixed
* the moment the proposal goes out and a group that disagrees about it gets no
* key rather than a weak one.
*
* It is also the one value picked *for* other people, and consent survives that:
* `t` rides on the proposal, every invitee's `acceptProposal` re-checks it against
* `quorumRange` and drops a proposal outside it, and the host-key approval gate is
* where each of them agrees to the `t`-of-`n` they can now see.
*
* Confirming opens the ceremony -- a NIP-17 room over the picked admins, and a
* ritual proposal in it -- and hands over to `DkgRitualViewModel`, which drives
* the remaining three steps. See `docs/subgroups.md`.
*/
class SelectSubgroupAdminsViewModel(
val activeUserPublicKey: HexKey,
val parentChatRoomId: String,
initialUIState: SelectSubgroupAdminsUIState,
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
val chatRepository: ChatRepository,
val dkgRepository: DkgRepository,
): ViewModel() {
var uiState: SelectSubgroupAdminsUIState by mutableStateOf(initialUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/** Members ticked, in the order they were ticked. The coordinator is never here. */
val selectedPublicKeys = mutableStateListOf<HexKey>()
val name: MutableState<String> = mutableStateOf("")
val threshold: MutableState<Int> = mutableStateOf(ChatRoomType.MINIMUM_QUORUM)
fun initiate() {
viewModelScope.launch(Dispatchers.IO) {
val parentRoom = chatRepository.getChatRoomByIdentifier(parentChatRoomId)
if (parentRoom == null) {
uiState = SelectSubgroupAdminsUIState.Error("Couldn't load this group")
return@launch
}
// Everybody but this device, since the coordinator is a participant by
// construction and is not theirs to tick or untick.
val candidates = parentRoom.localParticipants
.distinctBy { it.participant.participantPublicKey }
.filterNot { it.participant.participantPublicKey == activeUserPublicKey }
uiState = SelectSubgroupAdminsUIState.Loaded(
parentRoom = parentRoom,
candidates = candidates,
)
resolveKeyPackages(candidates.map { it.participant.participantPublicKey })
}
}
/**
* Asks every candidate's key package up front.
*
* The same move `SelectChatRoomMembersViewModel` makes for group creation, and
* here it is doing more than saving a round trip. A key package is
* one-time-use -- `NostrDao` marks the bundle consumed as the device processes
* its own Welcome -- so every group a member joins burns one, and a member who
* has not published since has none left. `MarmotGroupCreation` would refuse to
* create the room for that, correctly, but only after a ChillDKG, a parent
* quorum and a child quorum had all completed, each needing every selected
* admin present. Finding out here costs nothing and finding out there costs
* three ceremonies.
*/
private suspend fun resolveKeyPackages(publicKeys: List<HexKey>) {
val resolved = MarmotGroupCreation.keyPackagesFor(chatRepository, publicKeys)
val missing = resolved.filter { it.second == null }.map { it.first }.toSet()
val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return
uiState = loaded.copy(withoutKeyPackage = missing, isResolvingKeyPackages = false)
// A member who cannot be welcomed cannot be an admin of a room they are not
// in, so unpick them rather than leaving a selection that confirm will
// refuse.
selectedPublicKeys.removeAll(missing)
}
fun toggleMember(publicKey: HexKey) {
if (isActionPending.value) return
val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return
if (publicKey in loaded.withoutKeyPackage) return
if (!selectedPublicKeys.remove(publicKey)) {
selectedPublicKeys.add(publicKey)
}
// The range moves with the admin count, so a threshold picked at one size
// has to be pulled back into the range at another.
threshold.value = threshold.value.coerceIn(quorumRange())
}
fun isSelected(publicKey: HexKey): Boolean = selectedPublicKeys.contains(publicKey)
fun setName(value: String) {
if (isActionPending.value) return
name.value = value
}
/** The subgroup's admins: everyone ticked, plus the coordinator. */
fun adminCount(): Int = selectedPublicKeys.size + 1
fun quorumRange(): IntRange =
ChatRoomType.quorumRange(maxOf(adminCount(), ChatRoomType.MINIMUM_QUORUM))
fun setThreshold(value: Int) {
if (isActionPending.value) return
threshold.value = value.coerceIn(quorumRange())
}
/**
* Why this cannot be confirmed yet, or null when it can.
*
* Every message names what to do rather than what is wrong, because each of
* these is something the user can fix from where they are standing.
*/
suspend fun refusal(): String? {
val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return "Still loading"
if (name.value.isBlank()) return "Give the subgroup a name."
if (loaded.isResolvingKeyPackages) return "Checking who can be added…"
val unreachable = selectedPublicKeys.filter { it in loaded.withoutKeyPackage }
if (unreachable.isNotEmpty()) {
return "Some of the people picked have no key package. Ask them to " +
"publish one, then try again."
}
return chatRepository.refuseSubgroup(
parentChatRoomId = parentChatRoomId,
adminPublicKeys = selectedPublicKeys.toSet(),
coordinatorPublicKey = activeUserPublicKey
)
}
/**
* Opens the ceremony and hands over to the ritual screen.
*
* The room is stood up first and the proposal is the first thing out of it,
* exactly as a robust group is created: standing up a NIP-17 room sends
* nothing to anybody, so the proposal is how the other admins hear of it at
* all -- which is why it carries the name and the parent.
*/
fun confirm(onNavigateToRoute: (Route) -> Unit) {
if (isActionPending.value) return
val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return
val nostrPrivateKey = activeWalletStateFlow.value
?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
if (nostrPrivateKey == null) {
uiState = SelectSubgroupAdminsUIState.Error("Couldn't read your keys. Please try again.")
return
}
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
// Checked again here rather than trusted from the button: a screen not
// drawing something is not a guard, and the key-package answer can go
// stale while the screen is open.
val refusal = refusal()
if (refusal != null) {
isActionPending.value = false
uiState = SelectSubgroupAdminsUIState.Error(refusal)
return@launch
}
val ceremonyRoom = chatRepository.createNip17ChatRoom(
userPublicKey = activeUserPublicKey,
participantPublicKeys = selectedPublicKeys.toList(),
subject = name.value,
description = "Making ${name.value} a subgroup of " +
(loaded.parentRoom.chatRoom.subject ?: "this group") + "."
)
if (ceremonyRoom == null) {
isActionPending.value = false
uiState = SelectSubgroupAdminsUIState.Error(
"Couldn't start the subgroup's key ceremony. Please try again."
)
return@launch
}
val session = dkgRepository.proposeRitual(
localChatRoom = ceremonyRoom,
userPublicKey = activeUserPublicKey,
nostrPrivateKey = nostrPrivateKey.value.toByteArray(),
threshold = threshold.value,
parentChatRoomId = parentChatRoomId
)
isActionPending.value = false
if (session == null) {
logger.e("Created ${ceremonyRoom.chatRoom.id} without a key ceremony")
uiState = SelectSubgroupAdminsUIState.Error(
"Couldn't start the subgroup's key ceremony. Please try again."
)
return@launch
}
withContext(Dispatchers.Main) {
onNavigateToRoute(
DkgRitualRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = ceremonyRoom.chatRoom.id,
parentChatRoomId = parentChatRoomId
)
)
}
}
}
companion object {
private const val TAG = "SelectSubgroupAdminsViewModel"
fun factory(
activeUserPublicKey: HexKey,
parentChatRoomId: String,
initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading,
activeWalletStateFlow: StateFlow<ActiveWallet?>,
chatRepository: ChatRepository,
dkgRepository: DkgRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
SelectSubgroupAdminsViewModel(
activeUserPublicKey = activeUserPublicKey,
parentChatRoomId = parentChatRoomId,
initialUIState = initialUIState,
activeWalletStateFlow = activeWalletStateFlow,
chatRepository = chatRepository,
dkgRepository = dkgRepository
)
}
}
}
}

View File

@@ -1,6 +1,8 @@
package press.mantra.compose.ui.view.state
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.managers.SubgroupManager
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraDialect
@@ -29,6 +31,39 @@ sealed interface ChatRoomDetailUIState {
* checked. Null on a device that holds the reading and not the event.
*/
val signedGroupKeyStateEvent: GroupSignedEvent? = null,
/**
* The groups this one has certified as its children.
*
* Off the parent's own signed certificates, so it lists a subgroup this
* device holds no room for -- which is the normal position of a member
* who is not in it, and of everybody between the certificate being
* signed and the room being created.
*/
val subgroups: List<SubgroupManager.Subgroup> = emptyList(),
/**
* The group this room is a subgroup of, or null when it is nobody's
* child.
*
* Verified: it comes off the room's key state, which only carries a
* parent whose certificate was checked. A member welcomed in after the
* founding holds no state and so sees nothing here, even in a room that
* really is a subgroup.
*/
val parentChatRoomId: HexKey? = null,
/**
* Whether this device could open a subgroup here at all.
*
* Two conditions and both are about capability rather than permission.
* The room has to be a Marmot one, since a NIP-17 room has no admins to
* be a parent's. And this device has to hold a share of the group's key,
* because certifying a child is a signature by the parent and
* `FrostSigningManager.proposeSigningBatch` throws for a device that
* cannot make one -- throwing at a button is not a UI.
*
* The rest of the refusals are the picker's, where they can name who is
* missing and what to do about it.
*/
val canAddSubgroup: Boolean = false,
): ChatRoomDetailUIState
data class Error(

View File

@@ -63,6 +63,25 @@ sealed interface DkgRitualUIState {
* signed first. It is what gates creating the room.
*/
val isKeyStateSigned: Boolean = false,
/**
* The group this ceremony is making a subgroup of, or null for an
* ordinary one.
*
* A claim off the route and the proposal, not evidence. It decides which
* ladder the screen draws and nothing else -- what makes a lineage real
* is [isCertified] below, which is a signature by that group's quorum.
*/
val parentChatRoomId: HexKey? = null,
/** Whether the parent has certified the room this ceremony's key derives. */
val isCertified: Boolean = false,
/**
* The session asking the parent to certify, while it is running.
*
* Runs in the *parent's* room rather than this one, so it is watched
* separately from [keyStateSession] and cannot be read off this room's
* sessions at all.
*/
val certificateSession: FrostSigningSession? = null,
): DkgRitualUIState {
val hostKeyCount: Int get() = hostKeyParticipants.size
val round1Count: Int get() = round1Participants.size

View File

@@ -0,0 +1,37 @@
package press.mantra.compose.ui.view.state
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalParticipant
sealed interface SelectSubgroupAdminsUIState {
data class Loaded(
val parentRoom: LocalChatRoom,
/**
* Who can be picked: the parent's own members, admins and non-admins
* alike.
*
* Not filtered to the parent's admins on purpose. The point of a subgroup
* is that it can be run by people the parent does not let run the parent,
* so a picker that only offered existing admins would offer the one thing
* subgroups are not for.
*/
val candidates: List<LocalParticipant> = emptyList(),
/**
* Members with no unconsumed key package, who therefore cannot be
* welcomed into the room at step 4.
*
* Checked here rather than at creation because creation is three
* ceremonies later: a ChillDKG, a parent quorum and a child quorum all
* have to complete before `MarmotGroupCreation` would discover it, and
* each of those needed every selected admin to show up and approve.
*/
val withoutKeyPackage: Set<HexKey> = emptySet(),
/** Null while the lookup is still out; a set once it has settled. */
val isResolvingKeyPackages: Boolean = true,
): SelectSubgroupAdminsUIState
data class Error(val message: String): SelectSubgroupAdminsUIState
data object Loading: SelectSubgroupAdminsUIState
}