feat(dkg): schema 22 -- a ritual row belongs to a profile, not to a device
`DkgSession.id` is minted by its coordinator and carried on every message, so it is one string for every participant -- and two profiles on one device are two participants. Keyed by that id alone there was one row for both of them: `acceptProposal` asked "do I know this ceremony?", found the row the first profile wrote, and returned it. The second then ran the ritual out of the first's row: the first's host key, the first's randomness, and in the end the first's share, while the protocol had them as two members with two of each. `679d5249` refuses the second profile's *Welcome*, so this is unreachable for a Marmot group. A NIP-17 room has no Welcome and no equivalent check, and a group's own key ceremony is held in exactly such a room, so it was reachable where it matters most -- at the founding. **The key is the rule.** `(id, userPublicKey)` is "one row per participant per ceremony", and `DkgParticipantMessage` gains the receiving profile for two reasons: a message is an input to *that* profile's recomputation, alongside its own randomness, and a foreign key needs a unique parent to point at -- a child keyed by the ceremony alone could not reference the new parent at all, and deleting a room would leave its rituals' messages behind. Reads and key are one change, not two. Every query in `DkgSessionDao` now names a profile, because with this key a ceremony id or a room identifies no row -- it would return whichever the database ordered first, and hand back that profile's host key, randomness and share. `completedKey` and `canSign` take the asking profile and scope all three of their answers by it; `DatabaseDkgRepository` joins the chat and signing repositories in taking `activeIdentity` at construction, so a screen asking about "the ceremony" means its own and no call site can forget. One read names no profile and says so: `getCeremonyThresholdPublicKey` projects a single public column, the same value on every participant's row, for the two flows that want the room a ceremony's key derives. Scoping it would make a screen resolve nothing while no profile is open, for a fact that does not depend on one. The migration copies both tables into backups holding no foreign key before dropping anything, which is what makes it safe rather than the drop order: `DROP TABLE` fires cascades, and whether Room left them enabled is not a thing to depend on. `DkgSessionPerProfileMigrationJvmTest` runs with foreign keys **on** and passes with the two drops in either order, which is the evidence. Schema 22 rather than 21: the subgroups-multiple-parents line allocated 21 while this was being written, and the rule in docs/curated-to-mantra-profiles.md is that a version belongs to whichever tree migrates first. 21 leaves both ritual tables untouched, so the rebuild's DDL is unchanged apart from the versions it runs between. `TwoProfilesInOneCeremonyJvmTest` drives both profiles through one ceremony in one database; all five of its tests fail against the old lookup. `ProfileScopedSigningKeyJvmTest` covers `completedKey`'s three routes and the repository seam the UI reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a14ea3d9d3
commit
3c8489ad66
26 files changed
+7508
-153
No files matched your search
@@ -179,7 +179,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 21,
|
||||
version = 22,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -332,6 +332,19 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// would mean a column holding *one* of several parents, which is worse
|
||||
// than unused. See MIGRATION_20_21 and
|
||||
// docs/subgroups-multiple-parents.md.
|
||||
//
|
||||
// v22 keys a DkgSession by the profile that ran the ritual as well as by the
|
||||
// ceremony, and gives DkgParticipantMessage the profile that received each
|
||||
// message. A ceremony id is minted by its coordinator and travels on the
|
||||
// wire, so it is the same string for every participant -- including two
|
||||
// profiles on one device, which is two participants and was one row. The
|
||||
// second of them adopted the first's host key, its randomness and in the end
|
||||
// its share.
|
||||
//
|
||||
// Changing a primary key is not a shape Room migrates, and the column added
|
||||
// to the messages is NOT NULL off a value read from another table, so this is
|
||||
// manual too and passed to the builder rather than listed here. See
|
||||
// MIGRATION_21_22.
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
+7
-4
@@ -3,10 +3,11 @@ 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 press.mantra.compose.database.migrations.MIGRATION_20_21
|
||||
import press.mantra.compose.database.migrations.MIGRATION_9_10
|
||||
import press.mantra.compose.database.migrations.MIGRATION_13_14
|
||||
import press.mantra.compose.database.migrations.MIGRATION_14_15
|
||||
import press.mantra.compose.database.migrations.MIGRATION_20_21
|
||||
import press.mantra.compose.database.migrations.MIGRATION_21_22
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
|
||||
@@ -20,20 +21,22 @@ fun getRoomDatabase(
|
||||
builder: RoomDatabase.Builder<press.mantra.compose.database.MantraDatabase>
|
||||
): press.mantra.compose.database.MantraDatabase {
|
||||
return builder
|
||||
// Everything else Room generates itself. These five move data rather than
|
||||
// Everything else Room generates itself. These six move data rather than
|
||||
// only changing shape, which an AutoMigration cannot express: 3->4 rewrites
|
||||
// chat rows, 9->10 copies a session's per-event columns onto the items
|
||||
// table before dropping them, 13->14 renames a column and rewrites the
|
||||
// chat rows that named it the old way, 14->15 adds a column and deletes
|
||||
// the chat rows written for messages from before this device joined, and
|
||||
// 20->21 copies each room's single parentage onto the table that can hold
|
||||
// several before dropping the columns it came from.
|
||||
// several before dropping the columns it came from, and 21->22 rebuilds the
|
||||
// two ritual tables to key them by profile.
|
||||
.addMigrations(
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_9_10,
|
||||
MIGRATION_13_14,
|
||||
MIGRATION_14_15,
|
||||
MIGRATION_20_21
|
||||
MIGRATION_20_21,
|
||||
MIGRATION_21_22,
|
||||
)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.setQueryCoroutineContext(Dispatchers.IO)
|
||||
|
||||
+161
-29
@@ -9,23 +9,70 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Every read here names a profile, and that is not defensive repetition.
|
||||
*
|
||||
* `DkgSession`'s key is (`id`, `userPublicKey`) -- see the note on its entity --
|
||||
* so a query that gives only the ceremony id, or only the room, does not identify
|
||||
* a row. It would return whichever profile's the database happened to order first,
|
||||
* and what it would hand back is that profile's host key, randomness and share.
|
||||
* There is deliberately no unscoped overload to reach for.
|
||||
*/
|
||||
@Dao
|
||||
interface DkgSessionDao {
|
||||
@Query("SELECT * FROM DkgSession WHERE id = :sessionId")
|
||||
suspend fun getSessionById(sessionId: String): DkgSession?
|
||||
@Query("SELECT * FROM DkgSession WHERE id = :sessionId AND userPublicKey = :userPublicKey")
|
||||
suspend fun getSessionById(sessionId: String, userPublicKey: HexKey): DkgSession?
|
||||
|
||||
@Query("SELECT * FROM DkgSession WHERE id = :sessionId")
|
||||
fun observeSessionById(sessionId: String): Flow<DkgSession?>
|
||||
@Query("SELECT * FROM DkgSession WHERE id = :sessionId AND userPublicKey = :userPublicKey")
|
||||
fun observeSessionById(sessionId: String, userPublicKey: HexKey): Flow<DkgSession?>
|
||||
|
||||
/**
|
||||
* The key a ceremony produced, whichever profile is asking -- the one read here
|
||||
* that names no profile, and says so in its name.
|
||||
*
|
||||
* The scoping rule above is about rows that carry secrets. This projects a
|
||||
* single public column: the threshold key is the ceremony's published result and
|
||||
* is the same value on every participant's row by construction, so there is
|
||||
* nothing for a profile to disambiguate and nothing of anyone's to leak. What a
|
||||
* caller gets is what every member of the group already has.
|
||||
*
|
||||
* It exists because the alternative was worse in both directions. Scoping it
|
||||
* would make a screen resolve nothing while no profile is open, for a fact that
|
||||
* does not depend on one; leaving `getSessionById` unscoped would hand back a
|
||||
* whole row -- host key, randomness, share -- to answer a question about a
|
||||
* public key.
|
||||
*
|
||||
* Any row of the ceremony answers, so it takes one. A ceremony that has not
|
||||
* finished has the column null on every row and answers null, which the callers
|
||||
* want: both of them run from before it finishes.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT thresholdPublicKey FROM DkgSession WHERE id = :sessionId AND " +
|
||||
"thresholdPublicKey IS NOT NULL LIMIT 1"
|
||||
)
|
||||
suspend fun getCeremonyThresholdPublicKey(sessionId: String): HexKey?
|
||||
|
||||
/**
|
||||
* The ritual a room is currently running, newest first. A group may have
|
||||
* abandoned earlier attempts; the live one is the most recent.
|
||||
*/
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
fun observeLatestSessionForChatRoom(chatRoomId: String): Flow<DkgSession?>
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND userPublicKey = :userPublicKey " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
fun observeLatestSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey
|
||||
): Flow<DkgSession?>
|
||||
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND userPublicKey = :userPublicKey " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
suspend fun getLatestSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey
|
||||
): DkgSession?
|
||||
|
||||
/**
|
||||
* The room's newest ceremony **of its own**, newest first -- never one it is
|
||||
@@ -45,12 +92,20 @@ interface DkgSessionDao {
|
||||
* abused in is a member *excluding* a ceremony from this answer, and a
|
||||
* ceremony they can exclude is one they proposed, whose key they would
|
||||
* simply not have proposed at all.
|
||||
*
|
||||
* Scoped to [userPublicKey] because a room's rows are not: `ChatRoom` is keyed
|
||||
* by the group id alone, so two profiles on one device that are both in a group
|
||||
* hold their ceremonies of it side by side in one table. Only the asking
|
||||
* profile's own row carries a share it can sign with.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND parentChatRoomId IS NULL " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
"AND userPublicKey = :userPublicKey ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
suspend fun getLatestOwnSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
suspend fun getLatestOwnSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey
|
||||
): DkgSession?
|
||||
|
||||
/**
|
||||
* The room's newest ceremony *for one purpose*: the group's own when
|
||||
@@ -72,19 +127,29 @@ interface DkgSessionDao {
|
||||
*/
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " +
|
||||
"userPublicKey = :userPublicKey AND " +
|
||||
"(parentChatRoomId = :parentChatRoomId OR " +
|
||||
"(:parentChatRoomId IS NULL AND parentChatRoomId IS NULL)) " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
suspend fun getLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): DkgSession?
|
||||
suspend fun getLatestSessionFor(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
userPublicKey: HexKey
|
||||
): DkgSession?
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " +
|
||||
"userPublicKey = :userPublicKey AND " +
|
||||
"(parentChatRoomId = :parentChatRoomId OR " +
|
||||
"(:parentChatRoomId IS NULL AND parentChatRoomId IS NULL)) " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
fun observeLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): Flow<DkgSession?>
|
||||
fun observeLatestSessionFor(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
userPublicKey: HexKey
|
||||
): Flow<DkgSession?>
|
||||
|
||||
/**
|
||||
* The newest ceremony making a subgroup of [parentChatRoomId] over exactly
|
||||
@@ -111,13 +176,15 @@ interface DkgSessionDao {
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " +
|
||||
"parentChatRoomId = :parentChatRoomId AND " +
|
||||
"participantPublicKeys = :participantPublicKeys " +
|
||||
"participantPublicKeys = :participantPublicKeys AND " +
|
||||
"userPublicKey = :userPublicKey " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
suspend fun getLatestSubgroupSessionFor(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String,
|
||||
participantPublicKeys: String
|
||||
participantPublicKeys: String,
|
||||
userPublicKey: HexKey
|
||||
): DkgSession?
|
||||
|
||||
/**
|
||||
@@ -127,18 +194,34 @@ interface DkgSessionDao {
|
||||
* group, so the standing "this needs you" notice in the transcript cannot ask
|
||||
* about "the room's ceremony" -- it has to look at all of them and say which.
|
||||
*/
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
|
||||
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<DkgSession>>
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND userPublicKey = :userPublicKey " +
|
||||
"ORDER BY createdAt DESC"
|
||||
)
|
||||
fun observeSessionsForChatRoom(
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey
|
||||
): Flow<List<DkgSession>>
|
||||
|
||||
/**
|
||||
* Every ceremony this device came out of holding a share, newest first.
|
||||
* Every ceremony [userPublicKey] came out of holding a share, newest first.
|
||||
*
|
||||
* Signing happens in the admin room, whose id is derived from the key
|
||||
* rather than from the room the ceremony ran in, so the key is found by
|
||||
* matching that derivation rather than by a stored room id.
|
||||
*
|
||||
* One profile's, not the device's. A device holds as many of these as it has
|
||||
* profiles, and a share is only usable by the profile whose ceremony produced
|
||||
* it: the secret here reconstructs *that* participant's share, and the signer
|
||||
* id a session would use is that participant's index. Handing one profile the
|
||||
* other's ceremony answers "this group can sign" to somebody holding nothing
|
||||
* to sign with, which the UI reads as permission to offer the action.
|
||||
*/
|
||||
@Query("SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL ORDER BY createdAt DESC")
|
||||
suspend fun getKeyHoldingSessions(): List<DkgSession>
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL " +
|
||||
"AND userPublicKey = :userPublicKey ORDER BY createdAt DESC"
|
||||
)
|
||||
suspend fun getKeyHoldingSessions(userPublicKey: HexKey): List<DkgSession>
|
||||
|
||||
/**
|
||||
* Ceremonies opened to make a subgroup of [parentChatRoomId], newest first.
|
||||
@@ -153,8 +236,14 @@ interface DkgSessionDao {
|
||||
* of appearing in this list. It decides which screen to offer, not who may
|
||||
* do what.
|
||||
*/
|
||||
@Query("SELECT * FROM DkgSession WHERE parentChatRoomId = :parentChatRoomId ORDER BY createdAt DESC")
|
||||
suspend fun getByParentChatRoomId(parentChatRoomId: String): List<DkgSession>
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE parentChatRoomId = :parentChatRoomId AND " +
|
||||
"userPublicKey = :userPublicKey ORDER BY createdAt DESC"
|
||||
)
|
||||
suspend fun getByParentChatRoomId(
|
||||
parentChatRoomId: String,
|
||||
userPublicKey: HexKey
|
||||
): List<DkgSession>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(dkgSession: DkgSession)
|
||||
@@ -162,15 +251,58 @@ interface DkgSessionDao {
|
||||
@Upsert
|
||||
suspend fun upsert(dkgParticipantMessage: DkgParticipantMessage)
|
||||
|
||||
@Query("SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY participantPublicKey ASC")
|
||||
suspend fun getMessagesByKind(sessionId: String, kind: Kind): List<DkgParticipantMessage>
|
||||
/**
|
||||
* The messages [userPublicKey] has received of one round, in participant order.
|
||||
*
|
||||
* Ordered by sender, which is what makes it usable: the ritual derives each
|
||||
* participant's FROST id from the bytewise sort of the host keys, so a caller
|
||||
* reading this back gets the same order every device did.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND " +
|
||||
"userPublicKey = :userPublicKey AND kind = :kind ORDER BY participantPublicKey ASC"
|
||||
)
|
||||
suspend fun getMessagesByKind(
|
||||
sessionId: String,
|
||||
userPublicKey: HexKey,
|
||||
kind: Kind
|
||||
): List<DkgParticipantMessage>
|
||||
|
||||
@Query("SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId ORDER BY createdAt ASC")
|
||||
fun observeMessages(sessionId: String): Flow<List<DkgParticipantMessage>>
|
||||
@Query(
|
||||
"SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND " +
|
||||
"userPublicKey = :userPublicKey ORDER BY createdAt ASC"
|
||||
)
|
||||
fun observeMessages(
|
||||
sessionId: String,
|
||||
userPublicKey: HexKey
|
||||
): Flow<List<DkgParticipantMessage>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM DkgParticipantMessage WHERE sessionId = :sessionId AND kind = :kind")
|
||||
suspend fun countMessagesByKind(sessionId: String, kind: Kind): Int
|
||||
/**
|
||||
* How many of a round's messages [userPublicKey] holds -- which is how the
|
||||
* ritual knows whether a step can run.
|
||||
*
|
||||
* The count is of one profile's rows on purpose. Counting the device's would
|
||||
* reach the threshold while this profile had heard from nobody.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT COUNT(*) FROM DkgParticipantMessage WHERE sessionId = :sessionId AND " +
|
||||
"userPublicKey = :userPublicKey AND kind = :kind"
|
||||
)
|
||||
suspend fun countMessagesByKind(
|
||||
sessionId: String,
|
||||
userPublicKey: HexKey,
|
||||
kind: Kind
|
||||
): Int
|
||||
|
||||
@Query("SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND kind = :kind AND participantPublicKey = :participantPublicKey")
|
||||
suspend fun getMessage(sessionId: String, kind: Kind, participantPublicKey: HexKey): DkgParticipantMessage?
|
||||
@Query(
|
||||
"SELECT * FROM DkgParticipantMessage WHERE sessionId = :sessionId AND " +
|
||||
"userPublicKey = :userPublicKey AND kind = :kind AND " +
|
||||
"participantPublicKey = :participantPublicKey"
|
||||
)
|
||||
suspend fun getMessage(
|
||||
sessionId: String,
|
||||
userPublicKey: HexKey,
|
||||
kind: Kind,
|
||||
participantPublicKey: HexKey
|
||||
): DkgParticipantMessage?
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package press.mantra.compose.database.migrations
|
||||
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
|
||||
/**
|
||||
* Keys a ritual's row by the profile that ran it, not by the ceremony alone.
|
||||
*
|
||||
* `DkgSession.id` is minted by the coordinator and carried on the wire, so it is
|
||||
* the same string on every device and for every profile taking part. One row per
|
||||
* ceremony therefore meant one row for two participants whenever two profiles on
|
||||
* a device were both in a group: `ChillDkgRitualManager.acceptProposal` returned
|
||||
* the row the first of them wrote, and the second ran the ritual behind the
|
||||
* first's host key, randomness and share. The key becomes (`id`, `userPublicKey`),
|
||||
* and `DkgParticipantMessage` gains the receiving profile for the same reason --
|
||||
* see that entity for why its rows are a profile's rather than the ceremony's.
|
||||
*
|
||||
* Not an `AutoMigration`: Room cannot change a primary key, and the column this
|
||||
* adds is `NOT NULL` with a value that has to be read from another table.
|
||||
*
|
||||
* ### Copy before dropping anything
|
||||
*
|
||||
* `DROP TABLE` fires foreign keys. `DkgParticipantMessage` references
|
||||
* `DkgSession(id)` `ON DELETE CASCADE`, so dropping `DkgSession` while messages
|
||||
* still point at it deletes every one of them -- the same trap `MIGRATION_9_10`
|
||||
* documents and avoids. Whether it fires at all depends on whether Room left
|
||||
* foreign keys enforced around the migration, which is not a thing to depend on
|
||||
* in either direction.
|
||||
*
|
||||
* So the cascade is made harmless rather than avoided:
|
||||
*
|
||||
* 1. copy both tables into backups that carry **no foreign key at all**, so
|
||||
* nothing can cascade into them and no insert can fail a parent check;
|
||||
* 2. drop `DkgParticipantMessage`, then `DkgSession`;
|
||||
* 3. recreate both in their new shape and copy back from the backups;
|
||||
* 4. drop the backups.
|
||||
*
|
||||
* Step 1 is what makes this safe, and it is worth being exact about which part
|
||||
* carries the weight: by the time anything is dropped, every row exists in a table
|
||||
* that no cascade can reach, so a cascade firing in step 2 destroys only rows that
|
||||
* are about to be dropped regardless. `DkgSessionPerProfileMigrationJvmTest` runs
|
||||
* with foreign keys **on** and still passes with the two drops in either order,
|
||||
* which is the evidence for that.
|
||||
*
|
||||
* The child is dropped first anyway. It costs nothing, it keeps the intent legible
|
||||
* to the next person to touch this, and it means the migration does not rely on the
|
||||
* backups for correctness as well as for recovery.
|
||||
*
|
||||
* ### What the new column is filled with
|
||||
*
|
||||
* Each message takes the `userPublicKey` of the session it referenced, which is
|
||||
* unambiguous precisely because of the bug being fixed: before this there was one
|
||||
* session row per ceremony, so there is exactly one answer per message.
|
||||
*
|
||||
* A message whose session is missing has no answer and is dropped. The old
|
||||
* foreign key should have made that impossible; if one exists it was written
|
||||
* while foreign keys were off, and it could not satisfy the new key either --
|
||||
* keeping it would mean inventing an owner for somebody else's secret.
|
||||
*/
|
||||
val MIGRATION_21_22 = object : Migration(21, 22) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
// 1. Backups, deliberately without constraints of any kind.
|
||||
connection.execSQL(
|
||||
"CREATE TABLE `DkgSessionBackup` (" +
|
||||
"`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, " +
|
||||
"`coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, " +
|
||||
"`threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, " +
|
||||
"`participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, " +
|
||||
"`hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, " +
|
||||
"`round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, " +
|
||||
"`certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, " +
|
||||
"`publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, " +
|
||||
"`hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, " +
|
||||
"`round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, " +
|
||||
"`parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, " +
|
||||
"`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execSQL("INSERT INTO `DkgSessionBackup` SELECT * FROM `DkgSession`")
|
||||
|
||||
connection.execSQL(
|
||||
"CREATE TABLE `DkgParticipantMessageBackup` (" +
|
||||
"`sessionId` TEXT NOT NULL, `userPublicKey` TEXT, " +
|
||||
"`participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, " +
|
||||
"`payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execSQL(
|
||||
"INSERT INTO `DkgParticipantMessageBackup` " +
|
||||
"(`sessionId`, `userPublicKey`, `participantPublicKey`, `kind`, " +
|
||||
"`payload`, `createdAt`) " +
|
||||
"SELECT `m`.`sessionId`, " +
|
||||
"(SELECT `s`.`userPublicKey` FROM `DkgSession` AS `s` " +
|
||||
"WHERE `s`.`id` = `m`.`sessionId`), " +
|
||||
"`m`.`participantPublicKey`, `m`.`kind`, `m`.`payload`, `m`.`createdAt` " +
|
||||
"FROM `DkgParticipantMessage` AS `m`"
|
||||
)
|
||||
|
||||
// 2. The child first, so the parent's drop has nothing to cascade to -- though
|
||||
// the copies above are what actually make this safe.
|
||||
connection.execSQL("DROP TABLE `DkgParticipantMessage`")
|
||||
connection.execSQL("DROP TABLE `DkgSession`")
|
||||
|
||||
// 3. The new shapes. Both DDL strings are the ones Room generates for the
|
||||
// entities, so the schema Room validates against on open is this one.
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `DkgSession` (" +
|
||||
"`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, " +
|
||||
"`coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, " +
|
||||
"`threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, " +
|
||||
"`participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, " +
|
||||
"`hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, " +
|
||||
"`round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, " +
|
||||
"`certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, " +
|
||||
"`publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, " +
|
||||
"`hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, " +
|
||||
"`round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, " +
|
||||
"`parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, " +
|
||||
"`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`id`, `userPublicKey`), " +
|
||||
"FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` " +
|
||||
"ON `DkgSession` (`chatRoomId`)"
|
||||
)
|
||||
connection.execSQL("INSERT INTO `DkgSession` SELECT * FROM `DkgSessionBackup`")
|
||||
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `DkgParticipantMessage` (" +
|
||||
"`sessionId` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, " +
|
||||
"`participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, " +
|
||||
"`payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`sessionId`, `userPublicKey`, `participantPublicKey`, `kind`), " +
|
||||
"FOREIGN KEY(`sessionId`, `userPublicKey`) REFERENCES `DkgSession`(`id`, `userPublicKey`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId_userPublicKey` " +
|
||||
"ON `DkgParticipantMessage` (`sessionId`, `userPublicKey`)"
|
||||
)
|
||||
connection.execSQL(
|
||||
"INSERT INTO `DkgParticipantMessage` " +
|
||||
"SELECT `sessionId`, `userPublicKey`, `participantPublicKey`, `kind`, " +
|
||||
"`payload`, `createdAt` FROM `DkgParticipantMessageBackup` " +
|
||||
"WHERE `userPublicKey` IS NOT NULL"
|
||||
)
|
||||
|
||||
// 4.
|
||||
connection.execSQL("DROP TABLE `DkgParticipantMessageBackup`")
|
||||
connection.execSQL("DROP TABLE `DkgSessionBackup`")
|
||||
}
|
||||
}
|
||||
+26
-8
@@ -9,31 +9,49 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* One ritual message from one member, keyed so a duplicate delivery overwrites
|
||||
* rather than accumulates — relays redeliver, and feeding the same round-1
|
||||
* message in twice would make the coordinator's participant list the wrong
|
||||
* length.
|
||||
* One ritual message from one member, as one profile received it — keyed so a
|
||||
* duplicate delivery overwrites rather than accumulates, because relays redeliver
|
||||
* and feeding the same round-1 message in twice would make the coordinator's
|
||||
* participant list the wrong length.
|
||||
*
|
||||
* Covers the host key, round 1 and round 2; the coordinator's own broadcasts
|
||||
* live on [DkgSession] because there is only ever one of each.
|
||||
*
|
||||
* ### Why the receiving profile is part of the key
|
||||
*
|
||||
* The payload is a public protocol value and identical for everyone who sees it,
|
||||
* so a row looks at first like the ceremony's rather than a profile's. What is
|
||||
* per-profile is not the bytes but the *having received them*: `DkgSession`
|
||||
* deliberately stores inputs instead of protocol state, and the messages are
|
||||
* those inputs, recomputed from alongside that profile's own randomness. A
|
||||
* profile's view of the ritual is how far it can recompute, and that is what this
|
||||
* table holds.
|
||||
*
|
||||
* It also keeps the cascade. [DkgSession]'s key is (`id`, `userPublicKey`), and a
|
||||
* foreign key must name a unique parent, so a child keyed by the ceremony alone
|
||||
* could not reference it at all -- and deleting a room would leave its rituals'
|
||||
* messages behind.
|
||||
*/
|
||||
@Entity(
|
||||
primaryKeys = ["sessionId", "participantPublicKey", "kind"],
|
||||
primaryKeys = ["sessionId", "userPublicKey", "participantPublicKey", "kind"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = DkgSession::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sessionId"],
|
||||
parentColumns = ["id", "userPublicKey"],
|
||||
childColumns = ["sessionId", "userPublicKey"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("sessionId"),
|
||||
Index("sessionId", "userPublicKey"),
|
||||
],
|
||||
)
|
||||
data class DkgParticipantMessage(
|
||||
val sessionId: String,
|
||||
|
||||
/** Whose profile received it — the [DkgSession] row this belongs to. */
|
||||
val userPublicKey: HexKey,
|
||||
|
||||
/** The member's nostr public key — who sent it. */
|
||||
val participantPublicKey: HexKey,
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package press.mantra.compose.database.model
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
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
|
||||
@@ -31,6 +30,14 @@ import kotlin.time.Instant
|
||||
* assumption `MlsGroupState` already relies on.
|
||||
*/
|
||||
@Entity(
|
||||
// One ritual, one row *per profile*. [id] alone cannot be the key: it is minted
|
||||
// by the coordinator and carried on the wire, so it is the same string on every
|
||||
// device and for every profile that takes part. Two profiles on one device are
|
||||
// two participants of the ceremony, each with its own host key, its own
|
||||
// randomness and its own share, and keying by the ceremony alone let the second
|
||||
// of them adopt the first's row -- running the ritual behind one host key where
|
||||
// the protocol has two.
|
||||
primaryKeys = ["id", "userPublicKey"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ChatRoom::class,
|
||||
@@ -44,8 +51,13 @@ import kotlin.time.Instant
|
||||
],
|
||||
)
|
||||
data class DkgSession(
|
||||
/** Minted by the coordinator, carried on every message as `dkg_session`. */
|
||||
@PrimaryKey
|
||||
/**
|
||||
* Minted by the coordinator, carried on every message as `dkg_session`.
|
||||
*
|
||||
* The ceremony's identity, not the row's -- see the note on the key above, and
|
||||
* [userPublicKey] for the other half of it. Every tag that names a ceremony on
|
||||
* the wire carries this and nothing else, so it stays the value to put there.
|
||||
*/
|
||||
val id: String,
|
||||
|
||||
val chatRoomId: String,
|
||||
@@ -53,7 +65,14 @@ data class DkgSession(
|
||||
/** The member running the ritual — by construction, the room's creator. */
|
||||
val coordinatorPublicKey: HexKey,
|
||||
|
||||
/** Whose device this row belongs to, for multi-account support. */
|
||||
/**
|
||||
* Whose profile this row belongs to: the other half of the key.
|
||||
*
|
||||
* Not merely which account is open. It says whose host key [hostPublicKey] is,
|
||||
* whose randomness [round1Random] and [round2AuxRandom] are, and whose share
|
||||
* [secretShare] is -- so a read of any of those without this column is a read
|
||||
* of somebody else's secrets. Every query here carries it for that reason.
|
||||
*/
|
||||
val userPublicKey: HexKey,
|
||||
|
||||
/** The `t` of t-of-n, taken from the quorum chosen when the group was made. */
|
||||
|
||||
+14
-2
@@ -38,12 +38,22 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import press.mantra.compose.identity.Identity
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* @param activeIdentity whose shares [canSign] answers about -- see the same
|
||||
* parameter on `DatabaseFrostSigningRepository`, which this one's [canSign]
|
||||
* duplicates for the group detail screen. Defaults to nobody, which can sign
|
||||
* nothing.
|
||||
*/
|
||||
class DatabaseChatRepository(
|
||||
private val database: MantraDatabase,
|
||||
private val scope: CoroutineScope
|
||||
private val scope: CoroutineScope,
|
||||
private val activeIdentity: StateFlow<Identity?> = MutableStateFlow(null),
|
||||
): press.mantra.compose.repository.ChatRepository {
|
||||
val logger = Logger.withTag(TAG)
|
||||
|
||||
@@ -207,7 +217,9 @@ class DatabaseChatRepository(
|
||||
}
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean = try {
|
||||
FrostSigningManager.canSign(database, chatRoomId)
|
||||
activeIdentity.value?.nostrPublicKey?.let { userPublicKey ->
|
||||
FrostSigningManager.canSign(database, chatRoomId, userPublicKey)
|
||||
} ?: false
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error reading whether $chatRoomId can sign", e)
|
||||
false
|
||||
|
||||
+37
-10
@@ -21,19 +21,39 @@ import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import press.mantra.compose.identity.Identity
|
||||
|
||||
/**
|
||||
* @param activeIdentity whose rituals this answers about. A `DkgSession` is keyed by
|
||||
* the ceremony *and* the profile that ran it, so none of the reads below name a row
|
||||
* without one -- and the profile is the repository's to supply rather than the
|
||||
* screen's, which asks about the ritual it is showing and can mean no one else.
|
||||
* Defaults to nobody, which has no rituals, and is what a test with no identity
|
||||
* gets.
|
||||
*/
|
||||
class DatabaseDkgRepository(
|
||||
private val database: MantraDatabase,
|
||||
private val scope: CoroutineScope
|
||||
private val scope: CoroutineScope,
|
||||
private val activeIdentity: StateFlow<Identity?> = MutableStateFlow(null),
|
||||
): DkgRepository {
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
/** The profile asking, or null when nothing is open. */
|
||||
private val userPublicKey: HexKey? get() = activeIdentity.value?.nostrPublicKey
|
||||
|
||||
override fun observeSessionById(sessionId: String): Flow<DkgSession?> =
|
||||
database.dkgSessionDao().observeSessionById(sessionId)
|
||||
userPublicKey
|
||||
?.let { database.dkgSessionDao().observeSessionById(sessionId, it) }
|
||||
?: flowOf(null)
|
||||
|
||||
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<DkgSession>> =
|
||||
database.dkgSessionDao().observeSessionsForChatRoom(chatRoomId)
|
||||
userPublicKey
|
||||
?.let { database.dkgSessionDao().observeSessionsForChatRoom(chatRoomId, it) }
|
||||
?: flowOf(emptyList())
|
||||
|
||||
override suspend fun roomAwaitingCreation(session: DkgSession): String? {
|
||||
if (session.stage != DkgRitualStage.COMPLETE) return null
|
||||
@@ -49,13 +69,17 @@ class DatabaseDkgRepository(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String?,
|
||||
): Flow<DkgSession?> =
|
||||
database.dkgSessionDao().observeLatestSessionFor(chatRoomId, parentChatRoomId)
|
||||
userPublicKey
|
||||
?.let { database.dkgSessionDao().observeLatestSessionFor(chatRoomId, parentChatRoomId, it) }
|
||||
?: flowOf(null)
|
||||
|
||||
override fun observeMessages(sessionId: String): Flow<List<DkgParticipantMessage>> =
|
||||
database.dkgSessionDao().observeMessages(sessionId)
|
||||
userPublicKey
|
||||
?.let { database.dkgSessionDao().observeMessages(sessionId, it) }
|
||||
?: flowOf(emptyList())
|
||||
|
||||
override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? =
|
||||
database.dkgSessionDao().getLatestSessionForChatRoom(chatRoomId)
|
||||
userPublicKey?.let { database.dkgSessionDao().getLatestSessionForChatRoom(chatRoomId, it) }
|
||||
|
||||
override suspend fun proposeRitual(
|
||||
localChatRoom: LocalChatRoom,
|
||||
@@ -124,9 +148,10 @@ class DatabaseDkgRepository(
|
||||
// -- for a room holding only a subgroup's ceremony, asking for the
|
||||
// room's *own* found nothing at all, and a finished subgroup with
|
||||
// a signed key state could never be created.
|
||||
// The ceremony's own key, not a profile's row: what is wanted is the
|
||||
// room it derives, which every member agrees on.
|
||||
val subjectRoomId = database.dkgSessionDao()
|
||||
.getSessionById(dkgSessionId)
|
||||
?.thresholdPublicKey
|
||||
.getCeremonyThresholdPublicKey(dkgSessionId)
|
||||
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
|
||||
|
||||
subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
|
||||
@@ -180,8 +205,7 @@ class DatabaseDkgRepository(
|
||||
// rather than once: the ceremony has no key until it finishes,
|
||||
// and this flow is running before it does.
|
||||
val subgroupChatRoomId = database.dkgSessionDao()
|
||||
.getSessionById(dkgSessionId)
|
||||
?.thresholdPublicKey
|
||||
.getCeremonyThresholdPublicKey(dkgSessionId)
|
||||
?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
|
||||
?: return@map null
|
||||
|
||||
@@ -225,6 +249,9 @@ class DatabaseDkgRepository(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
sessionId = sessionId,
|
||||
// Whose approval it is. Nobody open approves nothing: the manager
|
||||
// reads the row for this profile, and there is no row for null.
|
||||
userPublicKey = userPublicKey ?: return,
|
||||
step = step,
|
||||
nostrPrivateKey = nostrPrivateKey
|
||||
)
|
||||
|
||||
+25
-3
@@ -7,18 +7,30 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.FrostSignerMessage
|
||||
import press.mantra.compose.database.model.FrostSigningItem
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
|
||||
import press.mantra.compose.identity.Identity
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
|
||||
/**
|
||||
* @param activeIdentity whose shares [canSign] answers about. A device holds as many
|
||||
* profiles as the user signed in, and a group one of them holds a share of is a
|
||||
* group the next is only reading; the question is therefore per-profile and the
|
||||
* answer is the repository's to scope, not the call site's -- a screen asking "can
|
||||
* this group sign" means itself, and cannot be made to mean anyone else. Defaults
|
||||
* to nobody, which can sign nothing, and is what a test with no identity gets.
|
||||
*/
|
||||
class DatabaseFrostSigningRepository(
|
||||
private val database: MantraDatabase,
|
||||
private val scope: CoroutineScope
|
||||
private val scope: CoroutineScope,
|
||||
private val activeIdentity: StateFlow<Identity?> = MutableStateFlow(null),
|
||||
): FrostSigningRepository {
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
@@ -40,8 +52,18 @@ class DatabaseFrostSigningRepository(
|
||||
override suspend fun getSessionById(sessionId: String): FrostSigningSession? =
|
||||
database.frostSigningSessionDao().getSessionById(sessionId)
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean =
|
||||
FrostSigningManager.canSign(database, chatRoomId)
|
||||
/**
|
||||
* Whether the active profile can sign for this room.
|
||||
*
|
||||
* Nobody active signs nothing. A read-only profile reaches here too and is
|
||||
* refused on the share rather than on the private key -- it holds no ceremony,
|
||||
* so there is nothing to find.
|
||||
*/
|
||||
override suspend fun canSign(chatRoomId: String): Boolean {
|
||||
val userPublicKey = activeIdentity.value?.nostrPublicKey ?: return false
|
||||
|
||||
return FrostSigningManager.canSign(database, chatRoomId, userPublicKey)
|
||||
}
|
||||
|
||||
override suspend fun proposeSigning(
|
||||
localChatRoom: LocalChatRoom,
|
||||
|
||||
+44
-17
@@ -207,7 +207,7 @@ object ChillDkgRitualManager {
|
||||
// requested threshold moot -- it settled that question when it opened.
|
||||
val running = if (parentChatRoomId == null) {
|
||||
database.dkgSessionDao()
|
||||
.getLatestSessionFor(localChatRoom.chatRoom.id, null)
|
||||
.getLatestSessionFor(localChatRoom.chatRoom.id, null, userPublicKey)
|
||||
?.takeIf { it.stage != DkgRitualStage.FAILED }
|
||||
} else {
|
||||
// Same parent, same admins -- and still running. A *completed* one is
|
||||
@@ -219,7 +219,8 @@ object ChillDkgRitualManager {
|
||||
database.dkgSessionDao().getLatestSubgroupSessionFor(
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
participantPublicKeys = DkgSession.formatParticipants(members).orEmpty()
|
||||
participantPublicKeys = DkgSession.formatParticipants(members).orEmpty(),
|
||||
userPublicKey = userPublicKey
|
||||
)?.takeIf {
|
||||
it.stage != DkgRitualStage.FAILED && it.stage != DkgRitualStage.COMPLETE
|
||||
}
|
||||
@@ -362,7 +363,7 @@ object ChillDkgRitualManager {
|
||||
// top of that -- so a round-1 message routinely lands before the
|
||||
// proposal that opens the ritual. The payload is already stored, and
|
||||
// [acceptProposal] replays it once the proposal turns up.
|
||||
database.dkgSessionDao().getSessionById(sessionId)
|
||||
database.dkgSessionDao().getSessionById(sessionId, userPublicKey)
|
||||
}
|
||||
|
||||
if (session == null) {
|
||||
@@ -381,12 +382,13 @@ object ChillDkgRitualManager {
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
sessionId = session.id,
|
||||
userPublicKey = session.userPublicKey,
|
||||
nostrPrivateKey = nostrPrivateKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a proposal and answers it with this device's host key. Returns the
|
||||
* Records a proposal and answers it with this profile's host key. Returns the
|
||||
* session, whether it was just created or already known.
|
||||
*/
|
||||
private suspend fun acceptProposal(
|
||||
@@ -397,7 +399,10 @@ object ChillDkgRitualManager {
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray
|
||||
): DkgSession? {
|
||||
database.dkgSessionDao().getSessionById(sessionId)?.let { return it }
|
||||
// This profile's row, not the device's. Two profiles in one group are two
|
||||
// participants of the ceremony, and the second of them finding the first's
|
||||
// row here is what made it run behind the first's host key and share.
|
||||
database.dkgSessionDao().getSessionById(sessionId, userPublicKey)?.let { return it }
|
||||
|
||||
// The participant set is the proposal's own p-tags plus its sender, NOT
|
||||
// this device's view of the room. `n` is hashed into the session identity,
|
||||
@@ -577,12 +582,17 @@ object ChillDkgRitualManager {
|
||||
// and a replay feeds the whole backlog through here again; the
|
||||
// upsert below absorbs that, but a chat row has no key to absorb it
|
||||
// with.
|
||||
val known = database.dkgSessionDao()
|
||||
.getMessage(session.id, innerEvent.kind, innerEvent.pubKey) != null
|
||||
val known = database.dkgSessionDao().getMessage(
|
||||
sessionId = session.id,
|
||||
userPublicKey = session.userPublicKey,
|
||||
kind = innerEvent.kind,
|
||||
participantPublicKey = innerEvent.pubKey
|
||||
) != null
|
||||
|
||||
database.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = session.id,
|
||||
userPublicKey = session.userPublicKey,
|
||||
participantPublicKey = innerEvent.pubKey,
|
||||
kind = innerEvent.kind,
|
||||
payload = innerEvent.content
|
||||
@@ -669,9 +679,10 @@ object ChillDkgRitualManager {
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
sessionId: String,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray
|
||||
) {
|
||||
var session = database.dkgSessionDao().getSessionById(sessionId) ?: return
|
||||
var session = database.dkgSessionDao().getSessionById(sessionId, userPublicKey) ?: return
|
||||
if (session.stage == DkgRitualStage.COMPLETE || session.stage == DkgRitualStage.FAILED) return
|
||||
|
||||
val hostSeckey = deriveHostSecretKey(nostrPrivateKey)
|
||||
@@ -845,7 +856,8 @@ object ChillDkgRitualManager {
|
||||
database: MantraDatabase,
|
||||
session: DkgSession
|
||||
): List<PublicKey>? {
|
||||
val hostKeys = database.dkgSessionDao().getMessagesByKind(session.id, DkgRitualEvents.HOST_KEY)
|
||||
val hostKeys = database.dkgSessionDao()
|
||||
.getMessagesByKind(session.id, session.userPublicKey, DkgRitualEvents.HOST_KEY)
|
||||
|
||||
if (hostKeys.size < session.participantCount) {
|
||||
logger.d("Ritual ${session.id}: ${hostKeys.size}/${session.participantCount} host keys")
|
||||
@@ -922,10 +934,11 @@ object ChillDkgRitualManager {
|
||||
kind: Kind
|
||||
): List<ByteArray>? {
|
||||
val hostKeyByParticipant = database.dkgSessionDao()
|
||||
.getMessagesByKind(session.id, DkgRitualEvents.HOST_KEY)
|
||||
.getMessagesByKind(session.id, session.userPublicKey, DkgRitualEvents.HOST_KEY)
|
||||
.associate { it.participantPublicKey to it.payload }
|
||||
|
||||
val messages = database.dkgSessionDao().getMessagesByKind(session.id, kind)
|
||||
val messages = database.dkgSessionDao()
|
||||
.getMessagesByKind(session.id, session.userPublicKey, kind)
|
||||
if (messages.size < session.participantCount) {
|
||||
logger.d("Ritual ${session.id}: ${messages.size}/${session.participantCount} of kind $kind")
|
||||
return null
|
||||
@@ -949,13 +962,20 @@ object ChillDkgRitualManager {
|
||||
.map { (_, payload) -> payload.hexToByteArray() }
|
||||
}
|
||||
|
||||
/** This device's own message of [kind], if it has published one. */
|
||||
/**
|
||||
* This profile's own message of [kind], if it has published one.
|
||||
*
|
||||
* The profile is both the row's owner and the sender here, which is why the
|
||||
* same value appears twice: this is the one message in the ritual that this
|
||||
* profile both received and wrote.
|
||||
*/
|
||||
private suspend fun ownMessage(
|
||||
database: MantraDatabase,
|
||||
session: DkgSession,
|
||||
kind: Kind
|
||||
): DkgParticipantMessage? = database.dkgSessionDao().getMessage(
|
||||
sessionId = session.id,
|
||||
userPublicKey = session.userPublicKey,
|
||||
kind = kind,
|
||||
participantPublicKey = session.userPublicKey
|
||||
)
|
||||
@@ -972,7 +992,8 @@ object ChillDkgRitualManager {
|
||||
session: DkgSession,
|
||||
transform: (DkgSession) -> DkgSession
|
||||
): DkgSession {
|
||||
val current = database.dkgSessionDao().getSessionById(session.id) ?: session
|
||||
val current = database.dkgSessionDao()
|
||||
.getSessionById(session.id, session.userPublicKey) ?: session
|
||||
val updated = transform(current)
|
||||
|
||||
if (updated == current) return current
|
||||
@@ -1010,7 +1031,8 @@ object ChillDkgRitualManager {
|
||||
// Read before writing so the notice goes out once. A ritual can be failed
|
||||
// from two directions -- a FAILURE message from a member, and a fault raised
|
||||
// locally -- and the group does not need to be told twice.
|
||||
val current = database.dkgSessionDao().getSessionById(session.id) ?: session
|
||||
val current = database.dkgSessionDao()
|
||||
.getSessionById(session.id, session.userPublicKey) ?: session
|
||||
if (current.stage == DkgRitualStage.FAILED) return
|
||||
|
||||
update(database, current) {
|
||||
@@ -1075,7 +1097,7 @@ object ChillDkgRitualManager {
|
||||
// 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)
|
||||
.countMessagesByKind(session.id, session.userPublicKey, DkgRitualEvents.HOST_KEY)
|
||||
|
||||
return if (hostKeys >= session.participantCount) DkgApprovalStep.ROUND_1 else null
|
||||
}
|
||||
@@ -1100,10 +1122,13 @@ object ChillDkgRitualManager {
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
sessionId: String,
|
||||
userPublicKey: HexKey,
|
||||
step: DkgApprovalStep,
|
||||
nostrPrivateKey: ByteArray
|
||||
) {
|
||||
val session = database.dkgSessionDao().getSessionById(sessionId) ?: return
|
||||
// Whose approval this is. A ritual id alone names no row, and approving on
|
||||
// behalf of the other profile on the device would publish its host key.
|
||||
val session = database.dkgSessionDao().getSessionById(sessionId, userPublicKey) ?: return
|
||||
|
||||
if (pendingApproval(database, session) != step) {
|
||||
logger.i("Ritual $sessionId is not waiting on $step; ignoring the approval")
|
||||
@@ -1125,6 +1150,7 @@ object ChillDkgRitualManager {
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
sessionId = sessionId,
|
||||
userPublicKey = userPublicKey,
|
||||
nostrPrivateKey = nostrPrivateKey
|
||||
)
|
||||
}
|
||||
@@ -1235,7 +1261,7 @@ object ChillDkgRitualManager {
|
||||
|
||||
/** The stored session, for reading a column back before overwriting it. */
|
||||
private suspend fun current(database: MantraDatabase, session: DkgSession): DkgSession =
|
||||
database.dkgSessionDao().getSessionById(session.id) ?: session
|
||||
database.dkgSessionDao().getSessionById(session.id, session.userPublicKey) ?: session
|
||||
|
||||
/**
|
||||
* Puts a ritual milestone in the group's chat.
|
||||
@@ -1311,6 +1337,7 @@ object ChillDkgRitualManager {
|
||||
database.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = session.id,
|
||||
userPublicKey = session.userPublicKey,
|
||||
participantPublicKey = session.userPublicKey,
|
||||
kind = kind,
|
||||
payload = content
|
||||
|
||||
+82
-22
@@ -160,9 +160,11 @@ object FrostSigningManager {
|
||||
* Everybody else leaves it null and is resolved from the room, which is the
|
||||
* only form that cannot name a ceremony the room has nothing to do with.
|
||||
*
|
||||
* Even then it is taken only if this device actually holds a share of it, so
|
||||
* naming one cannot talk the session into signing with material it has not
|
||||
* got; anything else falls back as though none was given.
|
||||
* Even then it is taken only if [userPublicKey] actually holds a share of it,
|
||||
* so naming one cannot talk the session into signing with material it has not
|
||||
* got -- nor with the material of the other profile on this device, which is
|
||||
* the same rule read across personas rather than across devices. Anything
|
||||
* else falls back as though none was given.
|
||||
*/
|
||||
suspend fun proposeSigning(
|
||||
database: MantraDatabase,
|
||||
@@ -244,8 +246,12 @@ object FrostSigningManager {
|
||||
dependents: (lead: Event) -> List<EventTemplate<*>>,
|
||||
key: DkgSession? = null
|
||||
): FrostSigningSession {
|
||||
val ceremony = key?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null }
|
||||
?: completedKey(database, localChatRoom.chatRoom.id)
|
||||
val ceremony = key?.takeIf {
|
||||
it.stage == DkgRitualStage.COMPLETE &&
|
||||
it.secretShare != null &&
|
||||
it.userPublicKey == userPublicKey
|
||||
}
|
||||
?: completedKey(database, localChatRoom.chatRoom.id, userPublicKey)
|
||||
?: throw IllegalStateException("This group has no shared key to sign with")
|
||||
|
||||
val signerId = signerIdOf(database, ceremony, userPublicKey)
|
||||
@@ -394,9 +400,13 @@ object FrostSigningManager {
|
||||
return null
|
||||
}
|
||||
|
||||
val key = database.dkgSessionDao().getSessionById(dkgSessionId)
|
||||
// Scoped to the profile being asked. The proposal names a ceremony, which is
|
||||
// a row per profile that took part; a device holding the other profile's
|
||||
// would otherwise answer here, and `signerIdOf` below would be looking up
|
||||
// this profile's index in a ceremony whose share belongs to the other.
|
||||
val key = database.dkgSessionDao().getSessionById(dkgSessionId, userPublicKey)
|
||||
if (key == null || key.stage != DkgRitualStage.COMPLETE) {
|
||||
logger.w("Signing proposal $sessionId names key $dkgSessionId, which this device does not hold; dropping")
|
||||
logger.w("Signing proposal $sessionId names key $dkgSessionId, which this profile does not hold; dropping")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -668,7 +678,8 @@ object FrostSigningManager {
|
||||
var session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return
|
||||
if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) return
|
||||
|
||||
val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return
|
||||
val key = database.dkgSessionDao()
|
||||
.getSessionById(session.dkgSessionId, session.userPublicKey) ?: return
|
||||
val secretShare = key.secretShare?.let { PrivateKey(ByteVector32(it)) } ?: return
|
||||
val thresholdPublicKey = key.thresholdPublicKey ?: return
|
||||
|
||||
@@ -1163,7 +1174,8 @@ object FrostSigningManager {
|
||||
session: FrostSigningSession,
|
||||
items: Int
|
||||
): List<Pair<Int, List<IndividualNonce>>>? {
|
||||
val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null
|
||||
val key = database.dkgSessionDao()
|
||||
.getSessionById(session.dkgSessionId, session.userPublicKey) ?: return null
|
||||
val idByMember = signerIds(database, key)
|
||||
|
||||
val offered = database.frostSigningSessionDao()
|
||||
@@ -1201,7 +1213,8 @@ object FrostSigningManager {
|
||||
signerIds: List<Int>,
|
||||
items: Int
|
||||
): List<List<ByteArray>>? {
|
||||
val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null
|
||||
val key = database.dkgSessionDao()
|
||||
.getSessionById(session.dkgSessionId, session.userPublicKey) ?: return null
|
||||
val memberById = signerIds(database, key).entries.associate { (member, id) -> id to member }
|
||||
|
||||
val payloadByMember = database.frostSigningSessionDao()
|
||||
@@ -1279,7 +1292,7 @@ object FrostSigningManager {
|
||||
key: DkgSession
|
||||
): Map<HexKey, Int> {
|
||||
val hostKeys = database.dkgSessionDao()
|
||||
.getMessagesByKind(key.id, DkgRitualEvents.HOST_KEY)
|
||||
.getMessagesByKind(key.id, key.userPublicKey, DkgRitualEvents.HOST_KEY)
|
||||
.associate { it.participantPublicKey to it.payload.lowercase() }
|
||||
|
||||
val order = hostKeys.values.sorted()
|
||||
@@ -1337,17 +1350,55 @@ object FrostSigningManager {
|
||||
* through here at all, because it names the ceremony outright: the room it
|
||||
* runs in is the NIP-17 room the ceremony was held in, which signs nothing
|
||||
* else and has no key state of its own.
|
||||
*
|
||||
* ### Whose key
|
||||
*
|
||||
* [userPublicKey] is the profile asking, and every one of the three answers is
|
||||
* scoped to it. A device holds several profiles, each with its own ceremonies
|
||||
* and its own groups, and every one of those rows is in this one table --
|
||||
* `ChatRoom` is keyed by the group id alone, so even the rooms are shared.
|
||||
* Nothing in a room, a key state or a threshold key says which profile is
|
||||
* asking; only the ceremony row does, in `DkgSession.userPublicKey`.
|
||||
*
|
||||
* Unscoped, each answer hands profile B a ceremony belonging to profile A, and
|
||||
* what comes back is a session whose `secretShare` is A's. B is not a
|
||||
* participant of it, so [signerIdOf] throws and the user meets a button that
|
||||
* fails having been offered -- which is the outcome this is read to avoid. It
|
||||
* is no escalation across users: both secrets are already one person's, on one
|
||||
* disk. It is the separation between personas, which is the whole of what a
|
||||
* profile is.
|
||||
*
|
||||
* The key-state lookup is filtered rather than scoped, because it cannot be
|
||||
* scoped: `DkgSession`'s primary key is the ceremony id the coordinator
|
||||
* minted, which is the same string on every participant's device, so there is
|
||||
* one row per ceremony per database and no second one to find. A state naming
|
||||
* a ceremony that belongs to another profile therefore resolves to nothing
|
||||
* here, which is the honest answer -- this profile has no share of it.
|
||||
*
|
||||
* That same single row is why two profiles *both* in one group is not merely
|
||||
* scoped here but refused: `ChillDkgRitualManager.acceptProposal` returns the
|
||||
* row the first of them wrote rather than writing a second, so the second
|
||||
* profile holds no ceremony of its own to find and cannot sign, where before
|
||||
* it would have signed with the first's share. Keying the row per profile is
|
||||
* the fix for that, and it is a migration rather than a predicate.
|
||||
*/
|
||||
suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? {
|
||||
suspend fun completedKey(
|
||||
database: MantraDatabase,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey
|
||||
): DkgSession? {
|
||||
GroupKeyStateManager.keyStateFor(database, chatRoomId)?.let { state ->
|
||||
database.dkgSessionDao().getSessionById(state.dkgSessionId)?.takeIf { key ->
|
||||
key.stage == DkgRitualStage.COMPLETE &&
|
||||
key.secretShare != null &&
|
||||
key.thresholdPublicKey == state.thresholdPublicKey
|
||||
}?.let { return it }
|
||||
database.dkgSessionDao()
|
||||
.getSessionById(state.dkgSessionId, userPublicKey)
|
||||
?.takeIf { key ->
|
||||
key.stage == DkgRitualStage.COMPLETE &&
|
||||
key.secretShare != null &&
|
||||
key.thresholdPublicKey == state.thresholdPublicKey
|
||||
}
|
||||
?.let { return it }
|
||||
}
|
||||
|
||||
database.dkgSessionDao().getKeyHoldingSessions().firstOrNull { session ->
|
||||
database.dkgSessionDao().getKeyHoldingSessions(userPublicKey).firstOrNull { session ->
|
||||
session.stage == DkgRitualStage.COMPLETE &&
|
||||
session.thresholdPublicKey?.let {
|
||||
SharedKeyDerivation.marmotGroupId(it) == chatRoomId
|
||||
@@ -1355,13 +1406,22 @@ object FrostSigningManager {
|
||||
}?.let { return it }
|
||||
|
||||
return database.dkgSessionDao()
|
||||
.getLatestOwnSessionForChatRoom(chatRoomId)
|
||||
.getLatestOwnSessionForChatRoom(chatRoomId, userPublicKey)
|
||||
?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null }
|
||||
}
|
||||
|
||||
/** Whether this group can sign at all, read by the UI so it offers nothing that would fail. */
|
||||
suspend fun canSign(database: MantraDatabase, chatRoomId: String): Boolean =
|
||||
completedKey(database, chatRoomId) != null
|
||||
/**
|
||||
* Whether [userPublicKey] can sign for this group at all, read by the UI so it
|
||||
* offers nothing that would fail.
|
||||
*
|
||||
* The profile is part of the question, not context around it. A group one
|
||||
* profile holds a share of is a group the next profile is merely looking at.
|
||||
*/
|
||||
suspend fun canSign(
|
||||
database: MantraDatabase,
|
||||
chatRoomId: String,
|
||||
userPublicKey: HexKey
|
||||
): Boolean = completedKey(database, chatRoomId, userPublicKey) != null
|
||||
|
||||
/**
|
||||
* The path off [key] that a signature made in this room is derived at, or
|
||||
|
||||
@@ -103,12 +103,16 @@ object SubgroupManager {
|
||||
val admins = adminPublicKeys + coordinatorPublicKey
|
||||
val parentChatRoomId = parentRoom.chatRoom.id
|
||||
|
||||
// A subgroup is certified by the parent's key, so a device holding no
|
||||
// A subgroup is certified by the parent's key, so a profile holding no
|
||||
// share of it cannot open the session at all --
|
||||
// `FrostSigningManager.proposeSigningBatch` throws outright, and throwing
|
||||
// at a button is not a UI. Checked before the size rules because it is the
|
||||
// one refusal the user cannot fix by picking differently.
|
||||
if (!FrostSigningManager.canSign(database, parentChatRoomId)) {
|
||||
//
|
||||
// Asked of the coordinator, who is the profile doing this. The device may
|
||||
// hold another profile's share of the same group; that is not a share this
|
||||
// one can sign the certificate with.
|
||||
if (!FrostSigningManager.canSign(database, parentChatRoomId, coordinatorPublicKey)) {
|
||||
return "Only a member who holds a share of this group's key can make a subgroup."
|
||||
}
|
||||
|
||||
@@ -176,7 +180,11 @@ object SubgroupManager {
|
||||
val running = database.dkgSessionDao().getLatestSubgroupSessionFor(
|
||||
chatRoomId = parentChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
participantPublicKeys = DkgSession.formatParticipants(admins).orEmpty()
|
||||
participantPublicKeys = DkgSession.formatParticipants(admins).orEmpty(),
|
||||
// The coordinator's own ceremonies. A ritual the other profile on this
|
||||
// device is running is not one this person can answer for, so it is no
|
||||
// reason to refuse them a ceremony of their own.
|
||||
userPublicKey = coordinatorPublicKey
|
||||
)
|
||||
if (running != null &&
|
||||
running.stage != DkgRitualStage.FAILED &&
|
||||
@@ -391,7 +399,9 @@ object SubgroupManager {
|
||||
// `proposeSigningBatch` throws outright, and throwing at a button is not
|
||||
// a UI. First, because it is the one refusal the user cannot fix by
|
||||
// picking differently.
|
||||
if (!FrostSigningManager.canSign(database, adoptingChatRoomId)) {
|
||||
// Asked of the profile doing the adopting. The device may hold another
|
||||
// profile's share of that group; it is not a share this one can certify with.
|
||||
if (!FrostSigningManager.canSign(database, adoptingChatRoomId, userPublicKey)) {
|
||||
return "Only a member who holds a share of that group's key can adopt a group into it."
|
||||
}
|
||||
|
||||
@@ -661,9 +671,9 @@ object SubgroupManager {
|
||||
val lineage = child.lineage()?.plus(parentage) ?: SubgroupLineage.of(parentage)
|
||||
checkNotNull(lineage) { "$childChatRoomId already names $parentChatRoomId as a parent" }
|
||||
|
||||
val key = FrostSigningManager.completedKey(database, childChatRoomId)
|
||||
val key = FrostSigningManager.completedKey(database, childChatRoomId, userPublicKey)
|
||||
?: throw IllegalStateException(
|
||||
"This device holds no share of $childChatRoomId's key to propose with"
|
||||
"This profile holds no share of $childChatRoomId's key to propose with"
|
||||
)
|
||||
|
||||
logger.i(
|
||||
@@ -719,9 +729,9 @@ object SubgroupManager {
|
||||
"$childChatRoomId does not name $parentChatRoomId as a parent"
|
||||
}
|
||||
|
||||
val key = FrostSigningManager.completedKey(database, childChatRoomId)
|
||||
val key = FrostSigningManager.completedKey(database, childChatRoomId, userPublicKey)
|
||||
?: throw IllegalStateException(
|
||||
"This device holds no share of $childChatRoomId's key to propose with"
|
||||
"This profile holds no share of $childChatRoomId's key to propose with"
|
||||
)
|
||||
|
||||
val remaining = current.minus(parentChatRoomId)
|
||||
|
||||
+9
-4
@@ -185,7 +185,9 @@ fun MantraNavHost(
|
||||
}
|
||||
|
||||
// Before the repositories: the nostr repository stamps every fetch request it queues
|
||||
// with the active identity, and takes the flow at construction.
|
||||
// with the active identity, the chat and signing repositories answer "can this group
|
||||
// sign" about it, the dkg repository reads the rituals belonging to it, and all four
|
||||
// take the flow at construction.
|
||||
val sovereignWalletViewModel: SovereignWalletViewModel = viewModel(
|
||||
factory = SovereignWalletViewModel.factory(
|
||||
phoenixGlobal = phoenixGlobal
|
||||
@@ -203,21 +205,24 @@ fun MantraNavHost(
|
||||
val databaseChatRepository = remember {
|
||||
DatabaseChatRepository(
|
||||
database = auxDatabaseManager.auxDatabase,
|
||||
applicationIOScope
|
||||
applicationIOScope,
|
||||
activeIdentity = sovereignWalletViewModel.activeIdentity,
|
||||
)
|
||||
}
|
||||
|
||||
val databaseDkgRepository = remember {
|
||||
DatabaseDkgRepository(
|
||||
database = auxDatabaseManager.auxDatabase,
|
||||
applicationIOScope
|
||||
applicationIOScope,
|
||||
activeIdentity = sovereignWalletViewModel.activeIdentity,
|
||||
)
|
||||
}
|
||||
|
||||
val databaseFrostSigningRepository = remember {
|
||||
DatabaseFrostSigningRepository(
|
||||
database = auxDatabaseManager.auxDatabase,
|
||||
applicationIOScope
|
||||
applicationIOScope,
|
||||
activeIdentity = sovereignWalletViewModel.activeIdentity,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user