feat: hold the ritual until its owner approves each step
The ChillDKG ritual ran entirely on its own. `acceptProposal` published this
device's host key the moment a PROPOSAL arrived from a relay, and `advance`
published rounds 1 and 2 as soon as their inputs landed. Receiving a nostr event
was therefore enough to enrol the owner of a phone in a group's permanent signing
quorum, without anything having been shown to them first.
Nothing of this device's own now goes out before its owner says so. Three
approvals, because each publishes something different and commits the member to
something different:
host key joins the ceremony, and fixes n. A member who joins and then stops
answering does not merely fail to help -- the ritual cannot finish
without every member, so they hold it open for everybody.
round 1 contributes to the key itself. The member's own secret material
starts shaping a key they will be expected to help sign with.
round 2 confirms the coordinator's combined result matches what this device
sent. A check rather than a formality: it is what stops a coordinator
substituting a key the members never contributed to.
The coordinator's two aggregations are deliberately not gated. They relay other
members' already-published messages and disclose nothing of the coordinator's own,
so an approval there would stall the whole group on one person's attention without
protecting anybody. The member who opens a ceremony is auto-approved for the host
key alone -- starting one is already the act of agreeing to be in it -- and is
still asked for rounds 1 and 2, which publish key material.
Each gate returns rather than throwing. The ritual is not failing, it is waiting
on a person; everything already received stays stored, so it resumes the moment
they approve. `pendingApproval` mirrors those gates exactly and has to keep doing
so: if the two disagree the screen offers an approval that does nothing, or none
while the ritual sits still.
Schema v2 -> v3 adds four nullable columns to DkgSession -- three approval
timestamps and `approvalRequestedThrough` -- so Room generates the migration. A
ritual already in flight comes back with all three null, which reads as "not
approved yet" and simply asks, rather than silently continuing.
## Being asked
Three screens rather than one parameterised by step, because each is making a
different case and the copy is the substance of the screen, not decoration around
it. They share a scaffold for one reason that is not cosmetic: a screen opened for
one step can go stale -- a redelivery carries the ritual forward, or the member
approves on another device -- so it re-checks the pending step before offering a
button, and `approve` checks again in the manager and ignores a mismatch.
"Not now" does not refuse on the member's behalf. There is no "no" in ChillDKG
short of abandoning the ceremony, and quietly leaving is what a member who is not
ready actually wants; abandoning stays on the ritual screen where the consequence
can be spelled out.
A chat line announces each request, written once per step and guarded by
`approvalRequestedThrough` -- `advance` runs on every arriving message and would
otherwise ask again on each one. It is the one ritual line that asks rather than
reports, so it is the one that is not quiet: primary tint, a Review affordance,
and a tap through to the ritual screen, whose bottom bar routes to the step the
ceremony is actually waiting on.
## Telling the steps apart
The request started as a single message type, which meant one icon for all three
and no way to tell "join the ceremony" from "confirm the key". The type is the
only thing a transcript keeps -- a line drawn days later has no session to ask
what was being requested -- so the step moved into it, one type per step, and
every stage now carries its own icon.
MIGRATION_3_4 rewrites the rows already written. They cannot regenerate: a request
is announced once, so a ceremony already in flight would keep its undifferentiated
icons forever. It changes no schema at all -- the version bump exists only to give
a data rewrite somewhere to run, which is why it is a manual migration on the
builder rather than another AutoMigration. Rows it cannot match keep the old type,
which the renderer still recognises.
An answered request shows a checkmark where Review was. Whether it was answered
comes from the transcript rather than the session: approving is the only thing
that causes the step to be published, and publishing writes an authored line, so a
matching line at or after the request means done. That keeps a room that has run
more than one ceremony correct -- ChatMessage has no session id to disambiguate
with -- and needs no DkgRepository in the message list. The comparison is on
createdAt rather than list position, because the list is ORDER BY createdAt DESC
with reverseLayout, where index arithmetic runs backwards.
Compiles and assembles; the ordering test still passes. No ritual has been run on
a device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -164,11 +164,20 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 2,
|
||||
version = 4,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
AutoMigration(from = 1, to = 2)
|
||||
AutoMigration(from = 1, to = 2),
|
||||
// v3 adds the three nullable approval timestamps to DkgSession. Nullable
|
||||
// additions need no default and drop no data, so Room generates this one
|
||||
// too. Rituals already in flight come back with all three null, which reads
|
||||
// as "not approved yet" and simply asks the member for each step.
|
||||
AutoMigration(from = 2, to = 3)
|
||||
// v4 changes no schema at all -- it rewrites `dkgApprovalNeeded` chat rows
|
||||
// into one type per ritual step. Data, not shape, so it is a manual
|
||||
// migration passed to the builder rather than an entry here. See
|
||||
// MIGRATION_3_4.
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
@@ -2,6 +2,7 @@ package press.mantra.compose.database.builder
|
||||
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import press.mantra.compose.database.migrations.MIGRATION_3_4
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
|
||||
@@ -15,6 +16,9 @@ fun getRoomDatabase(
|
||||
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
|
||||
): press.mantra.compose.database.MantraDatabase {
|
||||
return builder
|
||||
// Everything else Room generates itself; this one rewrites rows rather than
|
||||
// changing shape, which an AutoMigration cannot express.
|
||||
.addMigrations(MIGRATION_3_4)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.setQueryCoroutineContext(Dispatchers.IO)
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package press.mantra.compose.database.migrations
|
||||
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
|
||||
/**
|
||||
* Splits the single `dkgApprovalNeeded` chat line into one type per ritual step.
|
||||
*
|
||||
* The rows carried no step, so every request rendered with the same icon and a
|
||||
* reader could not tell "join the ceremony" from "confirm the key". The type is
|
||||
* the only thing the transcript keeps — a line drawn days later has no session to
|
||||
* ask — so the step had to move into it.
|
||||
*
|
||||
* Rewriting the existing rows rather than leaving them to the renderer's fallback,
|
||||
* because they cannot be regenerated: a request is announced once, guarded by
|
||||
* `DkgSession.approvalRequestedThrough`, so a ritual already in flight will never
|
||||
* write its request lines again. Without this, any ceremony started before the
|
||||
* split keeps its undifferentiated icons forever.
|
||||
*
|
||||
* Matched on content because that is the only thing distinguishing them, and the
|
||||
* three strings were written by this app one version ago. A row that matches none
|
||||
* of them keeps the old type, which the renderer still recognises — see
|
||||
* [ChatMessage.TYPE_DKG_APPROVAL_NEEDED_LEGACY]. This changes no schema; the
|
||||
* version bump exists only to give the data rewrite somewhere to run.
|
||||
*/
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
suspend fun rewrite(newType: String, contentPrefix: String) {
|
||||
connection.execSQL(
|
||||
"UPDATE ChatMessage SET messageType = '$newType' " +
|
||||
"WHERE messageType = '${ChatMessage.TYPE_DKG_APPROVAL_NEEDED_LEGACY}' " +
|
||||
"AND content LIKE '$contentPrefix%'"
|
||||
)
|
||||
}
|
||||
|
||||
rewrite(
|
||||
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY,
|
||||
"Your approval is needed to join the shared key ceremony."
|
||||
)
|
||||
rewrite(
|
||||
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1,
|
||||
"Everyone has joined the shared key ceremony."
|
||||
)
|
||||
rewrite(
|
||||
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2,
|
||||
"The contributions have been combined."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,50 @@ data class ChatMessage(
|
||||
const val TYPE_DKG_COMPLETE = "dkgComplete"
|
||||
const val TYPE_DKG_FAILED = "dkgFailed"
|
||||
|
||||
/**
|
||||
* The ritual is waiting on this device's owner and will publish nothing
|
||||
* until they act. Unauthored on purpose: these are addressed to the reader
|
||||
* rather than said by anyone, so they read "Your approval is needed", not
|
||||
* "You your approval is needed".
|
||||
*
|
||||
* One type per step rather than one for all three, because the row is the
|
||||
* only thing the transcript keeps: a line rendered days later has no session
|
||||
* to ask what was being requested, so which step it was has to be in the
|
||||
* type. Sharing one type left every request wearing the same icon.
|
||||
*/
|
||||
const val TYPE_DKG_APPROVAL_NEEDED_HOST_KEY = "dkgApprovalNeededHostKey"
|
||||
const val TYPE_DKG_APPROVAL_NEEDED_ROUND_1 = "dkgApprovalNeededRound1"
|
||||
const val TYPE_DKG_APPROVAL_NEEDED_ROUND_2 = "dkgApprovalNeededRound2"
|
||||
|
||||
/**
|
||||
* The single type the three above replaced. Still recognised so rows already
|
||||
* written keep rendering as system lines instead of turning into chat
|
||||
* bubbles attributed to the reader. Nothing writes it any more.
|
||||
*/
|
||||
const val TYPE_DKG_APPROVAL_NEEDED_LEGACY = "dkgApprovalNeeded"
|
||||
|
||||
/**
|
||||
* The line each request is answered by: the step it asked for, published by
|
||||
* this device. Approving is the only thing that causes that to be written,
|
||||
* so its presence is what marks a request done.
|
||||
*
|
||||
* The legacy type is absent on purpose -- it does not say which step it was
|
||||
* asking for, so nothing can be said about whether it was answered.
|
||||
*/
|
||||
val DKG_REQUEST_FULFILMENTS = mapOf(
|
||||
TYPE_DKG_APPROVAL_NEEDED_HOST_KEY to TYPE_DKG_HOST_KEY,
|
||||
TYPE_DKG_APPROVAL_NEEDED_ROUND_1 to TYPE_DKG_ROUND_1,
|
||||
TYPE_DKG_APPROVAL_NEEDED_ROUND_2 to TYPE_DKG_ROUND_2,
|
||||
)
|
||||
|
||||
/** The lines that are asking rather than reporting. */
|
||||
val DKG_REQUEST_TYPES = setOf(
|
||||
TYPE_DKG_APPROVAL_NEEDED_HOST_KEY,
|
||||
TYPE_DKG_APPROVAL_NEEDED_ROUND_1,
|
||||
TYPE_DKG_APPROVAL_NEEDED_ROUND_2,
|
||||
TYPE_DKG_APPROVAL_NEEDED_LEGACY,
|
||||
)
|
||||
|
||||
val DKG_TYPES = setOf(
|
||||
TYPE_DKG_STARTED,
|
||||
TYPE_DKG_HOST_KEY,
|
||||
@@ -119,7 +163,7 @@ data class ChatMessage(
|
||||
TYPE_DKG_CERTIFICATE,
|
||||
TYPE_DKG_COMPLETE,
|
||||
TYPE_DKG_FAILED,
|
||||
)
|
||||
) + DKG_REQUEST_TYPES
|
||||
|
||||
/**
|
||||
* The ritual lines somebody did, as opposed to ones that simply happened.
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import press.mantra.compose.database.model.traits.LocalStoreEntity
|
||||
import press.mantra.compose.database.model.traits.TimestampedEntity
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.time.Clock
|
||||
@@ -86,6 +87,39 @@ data class DkgSession(
|
||||
|
||||
val failureReason: String? = null,
|
||||
|
||||
/**
|
||||
* When this device's owner approved publishing their host key, and with it
|
||||
* joining the ceremony at all. Null until they do, and nothing of theirs goes
|
||||
* out before it is set.
|
||||
*
|
||||
* The ritual is otherwise driven entirely by arriving messages, which would
|
||||
* mean a member is enrolled in the group's permanent signing quorum by a relay
|
||||
* delivering an event to a phone in their pocket. Each of these three is a
|
||||
* separate decision because each publishes something different and commits the
|
||||
* member to something different -- see `ChillDkgRitualManager.pendingApproval`.
|
||||
*
|
||||
* Set at creation for the member who opened the ceremony: starting one is
|
||||
* already the act of agreeing to be in it.
|
||||
*/
|
||||
val hostKeyApprovedAt: Instant? = null,
|
||||
|
||||
/** When they approved publishing their contribution to the key. */
|
||||
val round1ApprovedAt: Instant? = null,
|
||||
|
||||
/** When they approved signing to confirm the combined result. */
|
||||
val round2ApprovedAt: Instant? = null,
|
||||
|
||||
/**
|
||||
* The furthest step this device has already asked its owner about, so the chat
|
||||
* line asking for it is written once.
|
||||
*
|
||||
* One column rather than a flag per step because the steps are strictly
|
||||
* ordered: reaching [DkgApprovalStep.ROUND_1] means the host key was asked for
|
||||
* and granted. ChatMessage has no session to key a row against and no unique
|
||||
* constraint to lean on, so the guard has to live here.
|
||||
*/
|
||||
val approvalRequestedThrough: DkgApprovalStep? = null,
|
||||
|
||||
override val createdAt: Instant = Clock.System.now(),
|
||||
override val updatedAt: Instant = createdAt,
|
||||
override val savedAt: Instant = createdAt,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package press.mantra.compose.database.model.types
|
||||
|
||||
/**
|
||||
* One thing a ChillDKG ritual needs this device's owner to agree to before it
|
||||
* will publish on their behalf.
|
||||
*
|
||||
* The ritual is driven by arriving messages, so without these it runs to
|
||||
* completion on its own — a relay delivering an event to a phone in a pocket is
|
||||
* enough to enrol its owner in a group's permanent signing quorum. Each step is a
|
||||
* separate decision because each publishes something different and commits the
|
||||
* member to something different, and each is presented on its own screen.
|
||||
*
|
||||
* The coordinator's two aggregations are deliberately not here. They relay other
|
||||
* members' already-published messages and disclose nothing of the coordinator's
|
||||
* own, so gating them would stall the whole group on one person's attention
|
||||
* without protecting anybody.
|
||||
*/
|
||||
enum class DkgApprovalStep {
|
||||
/**
|
||||
* Publish this device's host public key, which is what puts its owner in the
|
||||
* participant set. The most consequential of the three: `n` is fixed from here,
|
||||
* and every later step assumes this member is in.
|
||||
*/
|
||||
HOST_KEY,
|
||||
|
||||
/**
|
||||
* Publish this device's contribution to the key. Every member's is mixed in, so
|
||||
* this is the point at which the member's own secret material starts shaping a
|
||||
* key they will be expected to help sign with.
|
||||
*/
|
||||
ROUND_1,
|
||||
|
||||
/**
|
||||
* Sign to confirm the coordinator's combined result matches what this device
|
||||
* sent. Refusing here is what stops a coordinator substituting a different key
|
||||
* than the members contributed to, so it is a check, not a formality.
|
||||
*/
|
||||
ROUND_2
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import co.touchlab.kermit.Logger
|
||||
@@ -44,6 +45,30 @@ class DatabaseDkgRepository(
|
||||
null
|
||||
}
|
||||
|
||||
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? =
|
||||
ChillDkgRitualManager.pendingApproval(database, session)
|
||||
|
||||
override suspend fun approve(
|
||||
localChatRoom: LocalChatRoom,
|
||||
sessionId: String,
|
||||
step: DkgApprovalStep,
|
||||
nostrPrivateKey: ByteArray
|
||||
) {
|
||||
try {
|
||||
ChillDkgRitualManager.approve(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
sessionId = sessionId,
|
||||
step = step,
|
||||
nostrPrivateKey = nostrPrivateKey
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
// The ritual fails itself and tells the group; swallowing here keeps a
|
||||
// protocol fault from taking the screen down with it.
|
||||
logger.e("Error approving $step for DKG ritual $sessionId", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DatabaseDkgRepository"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.GiftWrapPayload
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
@@ -148,7 +149,12 @@ object ChillDkgRitualManager {
|
||||
// Fresh per session. Persisted because participantStep1/2 are pure in
|
||||
// it, which is what makes the whole ritual restart-safe.
|
||||
round1Random = RandomInstance.bytes(32).toHex(),
|
||||
round2AuxRandom = RandomInstance.bytes(32).toHex()
|
||||
round2AuxRandom = RandomInstance.bytes(32).toHex(),
|
||||
// Opening a ceremony is already the act of agreeing to be in it, so this
|
||||
// member is not asked again to publish the host key they just committed
|
||||
// the group to. Rounds 1 and 2 are still asked for: those publish key
|
||||
// material, and by then the ceremony has other people in it.
|
||||
hostKeyApprovedAt = Clock.System.now()
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
announceStarted(database, session)
|
||||
@@ -289,9 +295,14 @@ object ChillDkgRitualManager {
|
||||
database.dkgSessionDao().upsert(session)
|
||||
announceStarted(database, session)
|
||||
|
||||
logger.i("Joined DKG ritual $sessionId ($threshold-of-${members.size})")
|
||||
logger.i("Recorded DKG ritual $sessionId ($threshold-of-${members.size}); awaiting approval")
|
||||
|
||||
publishHostKey(database, localChatRoom, session)
|
||||
// Deliberately publishes nothing. This used to put the device's host key on
|
||||
// the wire, which meant a relay delivering an event was enough to enrol its
|
||||
// owner in the group's permanent signing quorum. The session is recorded so
|
||||
// the ritual can be shown and stored messages replayed; [advance] sends
|
||||
// nothing until [approve] has been called.
|
||||
announceApprovalNeeded(database, session, DkgApprovalStep.HOST_KEY)
|
||||
replayStoredMessages(database, session)
|
||||
|
||||
return session
|
||||
@@ -450,10 +461,27 @@ object ChillDkgRitualManager {
|
||||
val hostSeckey = deriveHostSecretKey(nostrPrivateKey)
|
||||
|
||||
try {
|
||||
// Nothing of this device's own goes out before its owner has said so.
|
||||
// Each gate returns rather than throwing: the ritual is not failing, it
|
||||
// is waiting on a person, and everything already received stays stored
|
||||
// so it resumes the moment they approve.
|
||||
if (session.hostKeyApprovedAt == null) return
|
||||
|
||||
if (ownMessage(database, session, DkgRitualEvents.HOST_KEY) == null) {
|
||||
publishHostKey(database, localChatRoom, session)
|
||||
}
|
||||
|
||||
// Waiting on host keys: the participant set isn't settled, so there is
|
||||
// nothing this device can compute yet.
|
||||
val hostPublicKeys = hostPublicKeys(database, session) ?: return
|
||||
|
||||
// A second decision, and a different one: the host key only joined the
|
||||
// ceremony, this contributes to the key itself.
|
||||
if (session.round1ApprovedAt == null) {
|
||||
announceApprovalNeeded(database, session, DkgApprovalStep.ROUND_1)
|
||||
return
|
||||
}
|
||||
|
||||
val step1 = ChillDKG.participantStep1(
|
||||
hostSecretKey = hostSeckey,
|
||||
hostPublicKeys = hostPublicKeys,
|
||||
@@ -482,6 +510,15 @@ object ChillDkgRitualManager {
|
||||
|
||||
val coordinatorRound1 = session.coordinatorRound1 ?: return
|
||||
|
||||
// The last decision, and a real check rather than a formality: signing
|
||||
// here asserts the combined result matches what this device sent, which
|
||||
// is what stops a coordinator swapping in a key the members never
|
||||
// contributed to.
|
||||
if (session.round2ApprovedAt == null) {
|
||||
announceApprovalNeeded(database, session, DkgApprovalStep.ROUND_2)
|
||||
return
|
||||
}
|
||||
|
||||
val step2 = ChillDKG.participantStep2(
|
||||
hostSecretKey = hostSeckey,
|
||||
state = step1.state,
|
||||
@@ -784,6 +821,124 @@ object ChillDkgRitualManager {
|
||||
actor = session.coordinatorPublicKey
|
||||
)
|
||||
|
||||
/**
|
||||
* What this ritual is waiting on its owner for, or null if it is waiting on
|
||||
* somebody else — or on nothing, being finished.
|
||||
*
|
||||
* Mirrors the gates in [advance] rather than duplicating their reasoning: a step
|
||||
* is pending exactly when [advance] would stop at it. The two must agree, or the
|
||||
* screen offers an approval that does nothing, or none while the ritual sits
|
||||
* still.
|
||||
*/
|
||||
suspend fun pendingApproval(database: MantraDatabase, session: DkgSession): DkgApprovalStep? {
|
||||
if (session.stage == DkgRitualStage.COMPLETE || session.stage == DkgRitualStage.FAILED) return null
|
||||
|
||||
if (session.hostKeyApprovedAt == null) return DkgApprovalStep.HOST_KEY
|
||||
|
||||
if (session.round1ApprovedAt == null) {
|
||||
// Nothing to contribute to until the participant set is settled: round 1
|
||||
// is computed over every member's host key, so asking earlier would be
|
||||
// asking about a ceremony whose shape is not yet known.
|
||||
val hostKeys = database.dkgSessionDao()
|
||||
.countMessagesByKind(session.id, DkgRitualEvents.HOST_KEY)
|
||||
|
||||
return if (hostKeys >= session.participantCount) DkgApprovalStep.ROUND_1 else null
|
||||
}
|
||||
|
||||
if (session.round2ApprovedAt == null) {
|
||||
// Nothing to check until the coordinator has combined the contributions.
|
||||
return if (session.coordinatorRound1 != null) DkgApprovalStep.ROUND_2 else null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that this device's owner agreed to [step], then lets the ritual run as
|
||||
* far as the next thing it is waiting on.
|
||||
*
|
||||
* Approving a step it is not actually waiting on is a no-op rather than an error:
|
||||
* a stale screen left open across a redelivery should not publish anything, and
|
||||
* the caller has no way to know the ritual has moved on since it was drawn.
|
||||
*/
|
||||
suspend fun approve(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
sessionId: String,
|
||||
step: DkgApprovalStep,
|
||||
nostrPrivateKey: ByteArray
|
||||
) {
|
||||
val session = database.dkgSessionDao().getSessionById(sessionId) ?: return
|
||||
|
||||
if (pendingApproval(database, session) != step) {
|
||||
logger.i("Ritual $sessionId is not waiting on $step; ignoring the approval")
|
||||
return
|
||||
}
|
||||
|
||||
val now = Clock.System.now()
|
||||
update(database, session) {
|
||||
when (step) {
|
||||
DkgApprovalStep.HOST_KEY -> it.copy(hostKeyApprovedAt = now)
|
||||
DkgApprovalStep.ROUND_1 -> it.copy(round1ApprovedAt = now)
|
||||
DkgApprovalStep.ROUND_2 -> it.copy(round2ApprovedAt = now)
|
||||
}
|
||||
}
|
||||
|
||||
logger.i("Ritual $sessionId: $step approved")
|
||||
|
||||
advance(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
sessionId = sessionId,
|
||||
nostrPrivateKey = nostrPrivateKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the group's chat that this ritual is waiting on the reader.
|
||||
*
|
||||
* The ritual is otherwise silent while it waits, and waiting is its normal
|
||||
* state: it cannot finish until every member has taken part, so a member who
|
||||
* never notices stalls it for everybody. The line is not addressed to the group
|
||||
* — every device writes its own, and each only ever writes its own owner's.
|
||||
*
|
||||
* Written once per step by [DkgSession.approvalRequestedThrough], because
|
||||
* [advance] runs on every arriving message and would otherwise ask again on each
|
||||
* one.
|
||||
*/
|
||||
private suspend fun announceApprovalNeeded(
|
||||
database: MantraDatabase,
|
||||
session: DkgSession,
|
||||
step: DkgApprovalStep
|
||||
) {
|
||||
val asked = current(database, session).approvalRequestedThrough
|
||||
if (asked != null && asked.ordinal >= step.ordinal) return
|
||||
|
||||
update(database, session) { it.copy(approvalRequestedThrough = step) }
|
||||
|
||||
val (messageType, content) = when (step) {
|
||||
DkgApprovalStep.HOST_KEY -> ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY to
|
||||
"Your approval is needed to join the shared key ceremony. Nothing has been " +
|
||||
"published from this device yet."
|
||||
|
||||
DkgApprovalStep.ROUND_1 -> ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 to
|
||||
"Everyone has joined the shared key ceremony. Your approval is needed to " +
|
||||
"contribute to the key."
|
||||
|
||||
DkgApprovalStep.ROUND_2 -> ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 to
|
||||
"The contributions have been combined. Your approval is needed to check the " +
|
||||
"result and confirm it."
|
||||
}
|
||||
|
||||
announce(
|
||||
database = database,
|
||||
session = session,
|
||||
messageType = messageType,
|
||||
content = content,
|
||||
actor = session.userPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts one protocol message in the group's chat, as a line naming who sent it.
|
||||
*
|
||||
|
||||
@@ -3,11 +3,18 @@ package press.mantra.compose.repository
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
/** Reads and starts ChillDKG rituals. Advancing one is the inbound path's job. */
|
||||
/**
|
||||
* Reads, starts and approves ChillDKG rituals.
|
||||
*
|
||||
* Advancing one is the inbound path's job, with one exception: a ritual waiting on
|
||||
* this device's owner does not advance until [approve] is called, because it will
|
||||
* not publish on their behalf until they say so.
|
||||
*/
|
||||
interface DkgRepository {
|
||||
/** The room's current ritual — abandoned attempts are superseded by the newest. */
|
||||
fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?>
|
||||
@@ -24,6 +31,17 @@ interface DkgRepository {
|
||||
threshold: Int
|
||||
): DkgSession?
|
||||
|
||||
/** What the ritual is waiting on this device's owner for, if anything. */
|
||||
suspend fun pendingApproval(session: DkgSession): DkgApprovalStep?
|
||||
|
||||
/** Agrees to one step, letting the ritual publish it and run on. */
|
||||
suspend fun approve(
|
||||
localChatRoom: LocalChatRoom,
|
||||
sessionId: String,
|
||||
step: DkgApprovalStep,
|
||||
nostrPrivateKey: ByteArray
|
||||
)
|
||||
|
||||
companion object {
|
||||
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
|
||||
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?> = flowOf(null)
|
||||
@@ -38,6 +56,15 @@ interface DkgRepository {
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
): DkgSession? = null
|
||||
|
||||
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = null
|
||||
|
||||
override suspend fun approve(
|
||||
localChatRoom: LocalChatRoom,
|
||||
sessionId: String,
|
||||
step: DkgApprovalStep,
|
||||
nostrPrivateKey: ByteArray
|
||||
) = Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.view.model.DkgRitualViewModel
|
||||
import press.mantra.compose.ui.view.state.DkgRitualUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import fr.acinq.phoenix.data.ActiveWallet
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The frame every ChillDKG approval screen sits in: the view model, the states
|
||||
* where there is nothing to approve, and the approve/decline bar.
|
||||
*
|
||||
* The three screens are separate because each is asking for something different
|
||||
* and has its own case to make. What they share is only the chrome and, more
|
||||
* importantly, the guard below — which is a correctness concern rather than a
|
||||
* cosmetic one and should not be re-argued three times.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun DkgApprovalScaffold(
|
||||
step: DkgApprovalStep,
|
||||
title: String,
|
||||
approveLabel: String,
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
onDone: () -> Unit,
|
||||
body: @Composable (DkgRitualUIState.Loaded) -> Unit,
|
||||
) {
|
||||
val dkgRitualViewModel: DkgRitualViewModel = viewModel(
|
||||
factory = DkgRitualViewModel.factory(
|
||||
chatRoomId = chatRoomId,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository
|
||||
)
|
||||
)
|
||||
|
||||
// Nothing loads the room or starts watching the session until this runs, so
|
||||
// without it the screen sits on its initial Loading state forever.
|
||||
LaunchedEffect(true) {
|
||||
dkgRitualViewModel.initiate()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (val state = dkgRitualViewModel.dkgRitualUIState) {
|
||||
is DkgRitualUIState.Loading -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
is DkgRitualUIState.Error -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
Text(text = state.message, textAlign = TextAlign.Center)
|
||||
}
|
||||
|
||||
is DkgRitualUIState.Loaded -> {
|
||||
// Loaded means the room is loaded, not the ritual: [initiate] sets
|
||||
// this state and only then starts collecting the session, so the
|
||||
// first emission always has a null session and a null pending step.
|
||||
// Reading that as "nothing to approve" would flash the empty state
|
||||
// on the way in, which looks exactly like the screen not working.
|
||||
if (state.session == null) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
// The ritual may have moved on since this screen was opened -- a
|
||||
// redelivery can carry it forward, and the member may have approved
|
||||
// on another device. Approving anyway would be approving a step that
|
||||
// is no longer the question, so the screen says so instead. The
|
||||
// manager checks this too; this is the half the member can see.
|
||||
if (state.pendingApproval != step) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding)
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "There is nothing to approve here right now. The ceremony has " +
|
||||
"either moved on or is waiting on somebody else.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
TextButton(onClick = onDone) { Text(text = "Back") }
|
||||
}
|
||||
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
val isActionPending = dkgRitualViewModel.isActionPending.value
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
body(state)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Declining is deliberately not a button that refuses on the
|
||||
// member's behalf: there is no "no" in ChillDKG short of
|
||||
// abandoning the ceremony, and quietly leaving is what a
|
||||
// member who is not ready actually wants. Abandoning is on
|
||||
// the ritual screen, where the consequence can be spelled out.
|
||||
TextButton(
|
||||
onClick = onDone,
|
||||
enabled = !isActionPending
|
||||
) {
|
||||
Text(text = "Not now")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
dkgRitualViewModel.approve(step)
|
||||
onDone()
|
||||
},
|
||||
enabled = !isActionPending,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (isActionPending) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Text(text = approveLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One "this is what happens" line on an approval screen. */
|
||||
@Composable
|
||||
internal fun DkgApprovalPoint(text: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(text = "•", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import fr.acinq.phoenix.data.ActiveWallet
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Asks whether to join a shared key ceremony somebody else opened.
|
||||
*
|
||||
* The first and most consequential of the three approvals. Publishing a host key
|
||||
* is what puts this member in the participant set, and the set is what `n` is
|
||||
* counted over — so a member who joins and then stops answering does not merely
|
||||
* fail to help, they hold the ceremony open for everybody else. Saying that
|
||||
* plainly here is the point of the screen.
|
||||
*
|
||||
* Until this is approved nothing has left the device, which is worth stating
|
||||
* outright: the member is being asked to start, not to continue.
|
||||
*/
|
||||
@Composable
|
||||
fun DkgJoinApprovalScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
DkgApprovalScaffold(
|
||||
step = DkgApprovalStep.HOST_KEY,
|
||||
title = "Join the key ceremony?",
|
||||
approveLabel = "Join the ceremony",
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository,
|
||||
onDone = onDone
|
||||
) { state ->
|
||||
val session = state.session
|
||||
val group = state.localChatRoom.chatRoom.subject ?: "this group"
|
||||
|
||||
Text(
|
||||
text = "$group is creating a shared key that takes " +
|
||||
"${session?.threshold ?: 0} of ${state.participantCount} members to sign with.",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
DkgApprovalPoint("Your device publishes the key it will be identified by for the rest of the ceremony.")
|
||||
DkgApprovalPoint("You will be asked twice more before anything of your key material goes out.")
|
||||
DkgApprovalPoint(
|
||||
"The ceremony cannot finish until every member has taken part, so joining and " +
|
||||
"then stopping leaves the group waiting."
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Nothing has been published from this device yet. Declining leaves the ceremony " +
|
||||
"for the others to abandon or wait on, and you can join later from the shared key screen.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,11 @@ import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.composable.widgets.profile.ProfileColor
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgJoinApprovalRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRound1ApprovalRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRound2ApprovalRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.view.model.DkgRitualViewModel
|
||||
import press.mantra.compose.ui.view.state.DkgRitualUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
@@ -82,6 +87,7 @@ fun DkgRitualScreen(
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
onNavigateToRoute: (Route) -> Unit = {},
|
||||
) {
|
||||
val dkgRitualViewModel: DkgRitualViewModel = viewModel(
|
||||
factory = DkgRitualViewModel.factory(
|
||||
@@ -123,6 +129,50 @@ fun DkgRitualScreen(
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
val pending = dkgRitualUIState.pendingApproval
|
||||
|
||||
// The ceremony is stopped, waiting on this member, and will stay
|
||||
// stopped until they act -- so this outranks everything else the
|
||||
// bar could offer.
|
||||
if (pending != null) {
|
||||
BottomAppBar(
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
onNavigateToRoute(
|
||||
when (pending) {
|
||||
DkgApprovalStep.HOST_KEY -> DkgJoinApprovalRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
DkgApprovalStep.ROUND_1 -> DkgRound1ApprovalRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
DkgApprovalStep.ROUND_2 -> DkgRound2ApprovalRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Key, contentDescription = null)
|
||||
Text(
|
||||
text = when (pending) {
|
||||
DkgApprovalStep.HOST_KEY -> "Review and join"
|
||||
DkgApprovalStep.ROUND_1 -> "Review and contribute"
|
||||
DkgApprovalStep.ROUND_2 -> "Review and confirm"
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {}
|
||||
)
|
||||
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
// Any member can open a ritual — the coordinator has no say in
|
||||
// the key — but only when there isn't one already running.
|
||||
if (canStartRitual) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import fr.acinq.phoenix.data.ActiveWallet
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Asks whether to contribute to the key itself.
|
||||
*
|
||||
* The step where the member's own secret material first shapes the outcome.
|
||||
* Joining settled who is in the ceremony; this settles what the key is, and once
|
||||
* it is out there is no version of the key that does not include it.
|
||||
*
|
||||
* The participant set is worth showing here rather than a count, because this is
|
||||
* the last moment at which "who am I making a key with" is still a question the
|
||||
* member can act on — everyone is now known, and the ceremony has not yet
|
||||
* committed to anything.
|
||||
*/
|
||||
@Composable
|
||||
fun DkgRound1ApprovalScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
DkgApprovalScaffold(
|
||||
step = DkgApprovalStep.ROUND_1,
|
||||
title = "Contribute to the shared key?",
|
||||
approveLabel = "Contribute",
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository,
|
||||
onDone = onDone
|
||||
) { state ->
|
||||
val session = state.session
|
||||
|
||||
Text(
|
||||
text = "Everyone has joined. ${state.participantCount} members are making a key that " +
|
||||
"takes ${session?.threshold ?: 0} of them to sign with.",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
DkgApprovalPoint(
|
||||
"Your device publishes its contribution to the key. Every member's is mixed in, " +
|
||||
"so no single device ever holds the whole key."
|
||||
)
|
||||
DkgApprovalPoint("Your share of it never leaves this device.")
|
||||
DkgApprovalPoint("You will be asked once more, to check the combined result before it is final.")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Taking part with these members",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
state.ritualMembers.forEach { member ->
|
||||
val key = member.participant.participantPublicKey
|
||||
|
||||
Text(
|
||||
text = if (key == activeUserPublicKey) {
|
||||
"You"
|
||||
} else {
|
||||
member.profile?.humanReadableNameOrPubkey() ?: key.take(12)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import fr.acinq.phoenix.data.ActiveWallet
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Asks whether to confirm the combined result.
|
||||
*
|
||||
* The one approval of the three that is a check rather than a decision. Every
|
||||
* member signs to say the coordinator's combined result matches what they
|
||||
* themselves sent, and it is that agreement — not the coordinator's word — which
|
||||
* makes the key final. A coordinator trying to substitute a key the members never
|
||||
* contributed to is caught precisely here.
|
||||
*
|
||||
* The check itself is done by the device, which has the member's own round-1
|
||||
* message to compare against and would refuse a mismatch outright. So this screen
|
||||
* is not asking the member to verify anything by eye; it is asking whether to put
|
||||
* their name to a result their device has already found consistent.
|
||||
*/
|
||||
@Composable
|
||||
fun DkgRound2ApprovalScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
activeWalletStateFlow: StateFlow<ActiveWallet?>,
|
||||
chatRepository: ChatRepository,
|
||||
dkgRepository: DkgRepository,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
DkgApprovalScaffold(
|
||||
step = DkgApprovalStep.ROUND_2,
|
||||
title = "Confirm the shared key?",
|
||||
approveLabel = "Confirm",
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
chatRepository = chatRepository,
|
||||
dkgRepository = dkgRepository,
|
||||
onDone = onDone
|
||||
) { state ->
|
||||
val session = state.session
|
||||
|
||||
Text(
|
||||
text = "Everyone's contributions have been combined into a " +
|
||||
"${session?.threshold ?: 0}-of-${state.participantCount} key.",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
DkgApprovalPoint(
|
||||
"Your device has checked the combined result against what it sent, and they match."
|
||||
)
|
||||
DkgApprovalPoint(
|
||||
"Confirming signs to say so. The key becomes final once every member has confirmed."
|
||||
)
|
||||
DkgApprovalPoint(
|
||||
"It is this step that stops anyone substituting a key the group did not make together."
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "This is the last thing the ceremony needs from you.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ import press.mantra.compose.ui.composable.ChatRoomCreationScreen
|
||||
import press.mantra.compose.ui.composable.ChatRoomDetailScreen
|
||||
import press.mantra.compose.ui.composable.ChatRoomMessagingScreen
|
||||
import press.mantra.compose.ui.composable.CreateProfileScreen
|
||||
import press.mantra.compose.ui.composable.DkgJoinApprovalScreen
|
||||
import press.mantra.compose.ui.composable.DkgRitualScreen
|
||||
import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen
|
||||
import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
|
||||
import press.mantra.compose.ui.composable.HomeScreen
|
||||
import press.mantra.compose.ui.composable.ImplementationPendingScreen
|
||||
import press.mantra.compose.ui.composable.KeyPackageManagementScreen
|
||||
@@ -56,7 +59,10 @@ import press.mantra.compose.ui.composable.navigation.routes.ChatRoomCreationRout
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.CreateProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgJoinApprovalRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRound1ApprovalRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRound2ApprovalRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.HomeRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.KeyPackageManagementRoute
|
||||
@@ -452,7 +458,46 @@ fun MantraNavHost(
|
||||
chatRoomId = route.chatRoomId,
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onNavigateToRoute = { approvalRoute ->
|
||||
navController.navigate(route = approvalRoute)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<DkgJoinApprovalRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<DkgJoinApprovalRoute>()
|
||||
|
||||
DkgJoinApprovalScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onDone = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable<DkgRound1ApprovalRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<DkgRound1ApprovalRoute>()
|
||||
|
||||
DkgRound1ApprovalScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onDone = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable<DkgRound2ApprovalRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<DkgRound2ApprovalRoute>()
|
||||
|
||||
DkgRound2ApprovalScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI,
|
||||
chatRepository = databaseChatRepository,
|
||||
dkgRepository = databaseDkgRepository,
|
||||
onDone = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable<SelectChatRoomTypeRoute> { backStackEntry ->
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Joining a shared-key ceremony somebody else opened.
|
||||
*
|
||||
* Its own route rather than a step on a shared one: each approval is a separate
|
||||
* decision with its own case to make, and being separately addressable is what
|
||||
* lets the chat line and the ritual screen send a member straight to the one the
|
||||
* ceremony is actually waiting on.
|
||||
*/
|
||||
@Serializable
|
||||
data class DkgJoinApprovalRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String
|
||||
): Route()
|
||||
@@ -0,0 +1,17 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Contributing this device's share to a shared key.
|
||||
*
|
||||
* Its own route rather than a step on a shared one: each approval is a separate
|
||||
* decision with its own case to make, and being separately addressable is what
|
||||
* lets the chat line and the ritual screen send a member straight to the one the
|
||||
* ceremony is actually waiting on.
|
||||
*/
|
||||
@Serializable
|
||||
data class DkgRound1ApprovalRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String
|
||||
): Route()
|
||||
@@ -0,0 +1,17 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Confirming the combined result of a shared-key ceremony.
|
||||
*
|
||||
* Its own route rather than a step on a shared one: each approval is a separate
|
||||
* decision with its own case to make, and being separately addressable is what
|
||||
* lets the chat line and the ritual screen send a member straight to the one the
|
||||
* ceremony is actually waiting on.
|
||||
*/
|
||||
@Serializable
|
||||
data class DkgRound2ApprovalRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String
|
||||
): Route()
|
||||
@@ -22,6 +22,12 @@ import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.CallMerge
|
||||
import androidx.compose.material.icons.filled.FactCheck
|
||||
import androidx.compose.material.icons.filled.PanTool
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material.icons.filled.WorkspacePremium
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.KeyOff
|
||||
@@ -255,6 +261,29 @@ class ChatMessageListViewModel(
|
||||
)
|
||||
} else {
|
||||
|
||||
// A request line is answered when the step it asked for
|
||||
// has since been published by this device -- which is
|
||||
// exactly what approving it does. The transcript already
|
||||
// records that as an authored line, so the answer is here
|
||||
// in the list rather than in the session, and it stays
|
||||
// right for a room that has run more than one ceremony.
|
||||
val answeredRequests = chatRoomDetailMessageListUIState
|
||||
.chatMessageList
|
||||
.mapNotNull { request ->
|
||||
val published = ChatMessage
|
||||
.DKG_REQUEST_FULFILMENTS[request.chatMessage.messageType]
|
||||
?: return@mapNotNull null
|
||||
|
||||
val done = chatRoomDetailMessageListUIState.chatMessageList.any {
|
||||
it.chatMessage.messageType == published &&
|
||||
it.chatMessage.isUserMessage &&
|
||||
it.chatMessage.createdAt >= request.chatMessage.createdAt
|
||||
}
|
||||
|
||||
request.chatMessage.id.takeIf { done }
|
||||
}
|
||||
.toSet()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
@@ -312,6 +341,7 @@ class ChatMessageListViewModel(
|
||||
if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) {
|
||||
RitualNotice(
|
||||
localChatMessage = localChatMessage,
|
||||
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
|
||||
onClick = onOpenSharedKey
|
||||
)
|
||||
return@items
|
||||
@@ -482,18 +512,44 @@ class ChatMessageListViewModel(
|
||||
@Composable
|
||||
private fun RitualNotice(
|
||||
localChatMessage: LocalChatMessage,
|
||||
isAnswered: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val chatMessage = localChatMessage.chatMessage
|
||||
|
||||
// One per stage. A ceremony puts a dozen-odd lines in a row into the transcript,
|
||||
// and with a single icon on all of them the reader has to actually read each to
|
||||
// tell "somebody joined" from "somebody contributed" from "you are being asked
|
||||
// for something". A request shares its stage's icon rather than getting a
|
||||
// distinct one: it is the same step, before rather than after, and the primary
|
||||
// tint and the Review affordance already say which.
|
||||
val icon = when (chatMessage.messageType) {
|
||||
ChatMessage.TYPE_DKG_STARTED -> Icons.Default.Key
|
||||
ChatMessage.TYPE_DKG_HOST_KEY -> Icons.Default.PersonAdd
|
||||
ChatMessage.TYPE_DKG_ROUND_1 -> Icons.Default.Upload
|
||||
ChatMessage.TYPE_DKG_COORDINATOR_ROUND_1 -> Icons.Default.CallMerge
|
||||
ChatMessage.TYPE_DKG_ROUND_2 -> Icons.Default.FactCheck
|
||||
ChatMessage.TYPE_DKG_CERTIFICATE -> Icons.Default.WorkspacePremium
|
||||
ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle
|
||||
ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline
|
||||
else -> Icons.Default.Key
|
||||
|
||||
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY -> Icons.Default.PersonAdd
|
||||
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload
|
||||
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck
|
||||
|
||||
else -> Icons.Default.PanTool
|
||||
}
|
||||
|
||||
val tint = when (chatMessage.messageType) {
|
||||
ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
|
||||
// The requests are the ritual lines that ask rather than report, and the ones
|
||||
// the ceremony cannot get past on its own. Everything else here is deliberately
|
||||
// quiet; these are not.
|
||||
// An answered request is history, not a summons: it keeps its stage's icon so
|
||||
// the step is still recognisable, but drops the colour and the call to action.
|
||||
val isRequest = chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES && !isAnswered
|
||||
|
||||
val tint = when {
|
||||
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
|
||||
isRequest -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
@@ -551,10 +607,24 @@ private fun RitualNotice(
|
||||
)
|
||||
}
|
||||
|
||||
if (isRequest) {
|
||||
Text(
|
||||
text = "Review",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else if (isAnswered) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "You approved this",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = "Open the shared key ceremony",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
@@ -112,7 +113,10 @@ class DkgRitualViewModel(
|
||||
val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
|
||||
?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
|
||||
|
||||
dkgRitualUIState = loaded.copy(session = session)
|
||||
dkgRitualUIState = loaded.copy(
|
||||
session = session,
|
||||
pendingApproval = session?.let { dkgRepository.pendingApproval(it) }
|
||||
)
|
||||
|
||||
// Re-point the message watcher at whatever session is current. An
|
||||
// abandoned ritual's counts must not keep ticking over the new one.
|
||||
@@ -130,7 +134,12 @@ class DkgRitualViewModel(
|
||||
dkgRitualUIState = current.copy(
|
||||
hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY),
|
||||
round1Participants = senders(DkgRitualEvents.ROUND_1),
|
||||
round2Participants = senders(DkgRitualEvents.ROUND_2)
|
||||
round2Participants = senders(DkgRitualEvents.ROUND_2),
|
||||
// Recomputed here as well as on the session, because
|
||||
// whether round 1 can be asked for depends on how many
|
||||
// host keys have arrived, which only this flow sees.
|
||||
pendingApproval = current.session
|
||||
?.let { dkgRepository.pendingApproval(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -172,6 +181,41 @@ class DkgRitualViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrees to whatever the ritual is currently waiting on from this device.
|
||||
*
|
||||
* Takes the step rather than reading it back from the state so a screen opened
|
||||
* for one step cannot approve a different one it has since moved on to — the
|
||||
* manager checks it against the ritual again and ignores a mismatch.
|
||||
*/
|
||||
fun approve(step: DkgApprovalStep) {
|
||||
if (isActionPending.value) return
|
||||
|
||||
val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
|
||||
val session = loaded.session ?: return
|
||||
|
||||
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
|
||||
if (nostrPrivateKey == null) {
|
||||
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dkgRepository.approve(
|
||||
localChatRoom = loaded.localChatRoom,
|
||||
sessionId = session.id,
|
||||
step = step,
|
||||
nostrPrivateKey = nostrPrivateKey.value.toByteArray()
|
||||
)
|
||||
|
||||
isActionPending.value = false
|
||||
// The session flow delivers the updated row and with it the next pending
|
||||
// step, so nothing is set here.
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
messageObserver?.cancel()
|
||||
super.onCleared()
|
||||
|
||||
@@ -4,6 +4,7 @@ import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalParticipant
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
|
||||
@@ -23,6 +24,15 @@ sealed interface DkgRitualUIState {
|
||||
val hostKeyParticipants: Set<HexKey> = emptySet(),
|
||||
val round1Participants: Set<HexKey> = emptySet(),
|
||||
val round2Participants: Set<HexKey> = emptySet(),
|
||||
/**
|
||||
* What the ritual is waiting on this device's owner for, if anything.
|
||||
*
|
||||
* Null covers two very different situations that look the same from here —
|
||||
* waiting on somebody else, and nothing left to wait for — because the
|
||||
* screen already distinguishes them from [session]'s stage. What it cannot
|
||||
* derive is this, which needs the stored messages as well as the row.
|
||||
*/
|
||||
val pendingApproval: DkgApprovalStep? = null,
|
||||
): DkgRitualUIState {
|
||||
val hostKeyCount: Int get() = hostKeyParticipants.size
|
||||
val round1Count: Int get() = round1Participants.size
|
||||
|
||||
Reference in New Issue
Block a user