Merge branch 'mantra' into claude/distracted-franklin-e95ba4

This commit is contained in:
Kgothatso Ngako committed 2026-09-05 23:24:45 +02:00
commit 5321e4af72
51 files changed
+15593 -608

No files matched your search

+9
View File
@@ -169,6 +169,15 @@ android {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
testOptions {
unitTests {
// Quartz logs through android.util.Log on the error paths -- parsing a
// malformed event, for one. Unmocked, those methods throw, so a test
// covering such a path fails on the log line rather than on what it
// came to check. Default values let the code under test carry on.
isReturnDefaultValues = true
}
}
}
dependencies {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -16,6 +16,7 @@ import press.mantra.compose.database.dao.ChatMessageNostrEventRelationDao
import press.mantra.compose.database.dao.ChatRoomDao
import press.mantra.compose.database.dao.ConnectionDao
import press.mantra.compose.database.dao.DkgSessionDao
import press.mantra.compose.database.dao.FrostSigningSessionDao
import press.mantra.compose.database.dao.GiftWrapMessageDao
import press.mantra.compose.database.dao.GiftWrapPayloadDao
import press.mantra.compose.database.dao.GiftWrapSealDao
@@ -102,6 +103,8 @@ import press.mantra.compose.database.model.Reaction
import press.mantra.compose.database.model.RecentSearch
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.Relay
import press.mantra.compose.database.model.RepostedRelation
import press.mantra.compose.database.model.SynchronizeNostrEventRequest
@@ -124,6 +127,8 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
ChatRoom::class,
DkgParticipantMessage::class,
DkgSession::class,
FrostSignerMessage::class,
FrostSigningSession::class,
GiftWrapMessage::class,
GiftWrapSeal::class,
GiftWrapPayload::class,
@@ -164,7 +169,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
UnsignedNostrEvent::class,
Zap::class
],
version = 4,
version = 6,
autoMigrations = [
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
// generate the migration itself — nothing existing changes shape.
@@ -173,11 +178,23 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
// 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)
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.
//
// v5 adds the nullable MarmotInnerEvent.payloadEventId, which names the
// nip30303 event a SubmissionEvent rumor carries. Rumors queued before
// this come back null, which reads as "not a submission" -- correct,
// since none of them were.
AutoMigration(from = 4, to = 5),
// v6 adds the FrostSigningSession/FrostSignerMessage tables and the
// nullable DkgSession.publicShares. New tables and a nullable column are
// both shapes Room can migrate itself. A ceremony that completed before
// this reads back null, and signing falls back to not cross-checking
// shares rather than refusing to run.
AutoMigration(from = 5, to = 6)
]
)
@ColumnTypeConverters(MantraConverters::class)
@@ -197,6 +214,8 @@ abstract class MantraDatabase: RoomDatabase() {
abstract fun dkgSessionDao(): DkgSessionDao
abstract fun frostSigningSessionDao(): FrostSigningSessionDao
abstract fun connectionDao(): ConnectionDao
abstract fun giftWrapMessageDao(): GiftWrapMessageDao
@@ -27,6 +27,16 @@ interface DkgSessionDao {
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
/**
* Every ceremony this device came out of holding a share, newest first.
*
* Signing happens in the #admins 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.
*/
@Query("SELECT * FROM DkgSession WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL ORDER BY createdAt DESC")
suspend fun getKeyHoldingSessions(): List<DkgSession>
@Upsert
suspend fun upsert(dkgSession: DkgSession)
@@ -0,0 +1,50 @@
package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
@Dao
interface FrostSigningSessionDao {
@Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId")
suspend fun getSessionById(sessionId: String): FrostSigningSession?
@Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId")
fun observeSessionById(sessionId: String): Flow<FrostSigningSession?>
/**
* A room's signing sessions, newest first. Unlike a DKG a group signs
* repeatedly, so there is no single "current" one to observe.
*/
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>>
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession?
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
suspend fun getSessionsForChatRoom(chatRoomId: String): List<FrostSigningSession>
@Upsert
suspend fun upsert(frostSigningSession: FrostSigningSession)
@Upsert
suspend fun upsert(frostSignerMessage: FrostSignerMessage)
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC")
suspend fun getMessagesByKind(sessionId: String, kind: Kind): List<FrostSignerMessage>
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId ORDER BY createdAt ASC")
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
@Query("SELECT COUNT(*) FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind")
suspend fun countMessagesByKind(sessionId: String, kind: Kind): Int
@Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind AND signerPublicKey = :signerPublicKey")
suspend fun getMessage(sessionId: String, kind: Kind, signerPublicKey: HexKey): FrostSignerMessage?
}
@@ -3,7 +3,10 @@ package press.mantra.compose.database.dao
import androidx.room3.Dao
import androidx.room3.Transaction
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.MantraArtifact
@@ -21,6 +24,7 @@ import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
@@ -28,6 +32,7 @@ import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_VISIBILITY
import press.mantra.compose.text.Markdown
import kotlin.time.Instant
@Dao
abstract class MantraDao(
@@ -35,6 +40,82 @@ abstract class MantraDao(
) {
val logger = Logger.withTag("NostrDao")
/**
* The unsigned nip30303 event [template] describes, authored by [publicKey].
*
* Its id is computed the same way the matching Mantra* entity computes
* its own, so the row on disk and the payload on the wire are the same
* event rather than two copies of one.
*/
private fun rumorOf(
template: EventTemplate<out Event>,
publicKey: HexKey,
): Event = Event(
id = EventHasher.hashId(
pubKey = publicKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
),
pubKey = publicKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
// A rumor. What signs for this reaching the group is the kind:445 the
// outbound pipeline wraps it in, not the payload itself.
sig = "",
)
/**
* Queue [payload] for the group inside a [SubmissionEvent] that
* [submitterPublicKey] authors.
*
* nip30303 events are never sent on their own. Wrapping them means the
* payload keeps whoever wrote it as its author while the group still knows
* which member put it there -- see [SubmissionEvent] for why the two are
* worth telling apart.
*
* The submission is stored as an unprocessed rumor (null marmotGroupEventId),
* which is what the outbound pipeline picks up and encrypts into a kind:445
* group event for the chat room.
*/
private suspend fun submitToGroup(
chatRoomId: String,
submitterPublicKey: HexKey,
payload: Event,
text: String,
): MarmotInnerEvent {
val submissionTemplate = SubmissionEvent.build(payload = payload)
val submissionInnerEvent = MarmotInnerEvent(
id = EventHasher.hashId(
pubKey = submitterPublicKey,
createdAt = submissionTemplate.createdAt,
kind = submissionTemplate.kind,
tags = submissionTemplate.tags,
content = submissionTemplate.content,
),
publicKey = submitterPublicKey,
kind = SubmissionEvent.KIND,
createdAt = Instant.fromEpochSeconds(submissionTemplate.createdAt),
tags = submissionTemplate.tags,
content = submissionTemplate.content,
payloadEventId = payload.id,
chatRoomId = chatRoomId,
)
sendMarmotInnerEvent(
chatRoomId = chatRoomId,
userPublicKey = submitterPublicKey,
text = text,
marmotInnerEvent = submissionInnerEvent,
)
return submissionInnerEvent
}
@Transaction
open suspend fun addDialect(
localChatRoom: LocalChatRoom,
@@ -42,7 +123,7 @@ abstract class MantraDao(
country: String,
language: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraDialect? {
logger.d("addDialect: $name")
val dialectEventTemplate = DialectEvent.build(
@@ -57,26 +138,17 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
) ?: return null
val dialectInnerEvent = MarmotInnerEvent(
id = mantraDialect.id,
publicKey = mantraDialect.publicKey,
kind = DialectEvent.KIND,
createdAt = mantraDialect.createdAt,
tags = dialectEventTemplate.tags,
content = dialectEventTemplate.content,
chatRoomId = mantraDialect.chatRoomId,
)
return try {
database.mantraDialectDao().upsert(mantraDialect)
database.marmotInnerEventDao().upsert(dialectInnerEvent)
sendMarmotInnerEvent(
localChatRoom = localChatRoom,
submitToGroup(
chatRoomId = mantraDialect.chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(dialectEventTemplate, userPublicKey),
text = "Added $name as a dialect",
marmotInnerEvent = dialectInnerEvent
)
dialectInnerEvent
mantraDialect
} catch (error: Throwable) {
logger.e("Failed to add dialect \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
null
@@ -94,7 +166,7 @@ abstract class MantraDao(
userPublicKey: HexKey,
visibility: String = DEFAULT_VISIBILITY,
license: String = DEFAULT_LICENSE,
): MarmotInnerEvent? {
): MantraArtifact? {
// TODO: Verify the active user is an admin of the chat room before allowing this.
val artifactEventTemplate = ArtifactEvent.build(
name = name,
@@ -110,32 +182,19 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
) ?: return null
// Persist the artifact together with an unprocessed marmot inner event
// (a rumor). Inner events with a null marmotGroupEventId are later
// picked up by the outbound pipeline and encrypted into a kind:445
// group event for the chat room.
// Persist the artifact locally and submit it to the group.
//
// This covers the "private" visibility case. Permissioned artifacts
// (published as a PublicMessage) and public artifacts (published as a
// plain nostr event) are not implemented yet.
val artifactInnerEvent = MarmotInnerEvent(
id = mantraArtifact.id,
publicKey = mantraArtifact.publicKey,
kind = ArtifactEvent.KIND,
createdAt = mantraArtifact.createdAt,
tags = artifactEventTemplate.tags,
content = artifactEventTemplate.content,
chatRoomId = mantraArtifact.chatRoomId,
)
return try {
database.mantraArtifactDao().upsert(mantraArtifact)
database.marmotInnerEventDao().upsert(artifactInnerEvent)
sendMarmotInnerEvent(
localChatRoom = localChatRoom,
submitToGroup(
chatRoomId = mantraArtifact.chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(artifactEventTemplate, userPublicKey),
text = "Added $name to artifacts",
marmotInnerEvent = artifactInnerEvent
)
// Every artifact starts with an initial version.
@@ -146,7 +205,7 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
)
artifactInnerEvent
mantraArtifact
} catch (error: Throwable) {
logger.e("Failed to add artifact \"$name\" to chat room ${localChatRoom.chatRoom.id}", error)
null
@@ -159,7 +218,7 @@ abstract class MantraDao(
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraArtifactVersion? {
return addArtifactVersionInternal(
localChatRoom = localChatRoom,
artifactId = artifactId,
@@ -173,7 +232,7 @@ abstract class MantraDao(
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraArtifactVersion? {
// The version label is carried in the event content (see
// MantraArtifactVersion.fromArtifactVersionEvent).
val artifactVersionEventTemplate = ArtifactVersionEvent.build(
@@ -188,25 +247,16 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
) ?: return null
val versionInnerEvent = MarmotInnerEvent(
id = mantraArtifactVersion.id,
publicKey = mantraArtifactVersion.publicKey,
kind = ArtifactVersionEvent.KIND,
createdAt = mantraArtifactVersion.createdAt,
tags = artifactVersionEventTemplate.tags,
content = artifactVersionEventTemplate.content,
chatRoomId = mantraArtifactVersion.chatRoomId,
)
database.mantraArtifactVersionDao().upsert(mantraArtifactVersion)
database.marmotInnerEventDao().upsert(versionInnerEvent)
sendMarmotInnerEvent(
localChatRoom = localChatRoom,
submitToGroup(
chatRoomId = mantraArtifactVersion.chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(artifactVersionEventTemplate, userPublicKey),
text = "Add the ${artifactVersionEventTemplate.content} version",
marmotInnerEvent = versionInnerEvent
)
return versionInnerEvent
return mantraArtifactVersion
}
@Transaction
@@ -216,7 +266,7 @@ abstract class MantraDao(
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraChapter? {
// Chapters attach to an artifact version; use the latest one.
val version = database.mantraArtifactVersionDao()
@@ -244,24 +294,12 @@ abstract class MantraDao(
return try {
database.mantraChapterDao().upsert(chapter)
val chapterInnerEvent = MarmotInnerEvent(
id = chapter.id,
publicKey = chapter.publicKey,
kind = ChapterEvent.KIND,
createdAt = chapter.createdAt,
tags = chapterEventTemplate.tags,
content = chapterEventTemplate.content,
chatRoomId = chapter.chatRoomId,
)
database.marmotInnerEventDao().upsert(
chapterInnerEvent
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(chapterEventTemplate, userPublicKey),
text = "Added chapter to artifact", // TODO: Get artifact to use in text...
marmotInnerEvent = chapterInnerEvent
)
// Split the markdown into paragraph chunks.
@@ -279,30 +317,17 @@ abstract class MantraDao(
userPublicKey = userPublicKey,
)?.let { chunk ->
database.mantraChunkDao().upsert(chunk)
val chunkInnerEvent = MarmotInnerEvent(
id = chunk.id,
publicKey = chunk.publicKey,
kind = ChunkEvent.KIND,
createdAt = chunk.createdAt,
tags = chunkEventTemplate.tags,
content = chunkEventTemplate.content,
chatRoomId = chunk.chatRoomId,
)
database.marmotInnerEventDao().upsert(
chunkInnerEvent
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(chunkEventTemplate, userPublicKey),
text = "${chapter.name} added chunk $chunkIndex", // TODO: Use a portion of the actual chunked text...
marmotInnerEvent = chunkInnerEvent
)
}
}
chapterInnerEvent
chapter
} catch (error: Throwable) {
logger.e("Failed to add chapter \"$name\" to artifact $artifactId", error)
null
@@ -315,7 +340,7 @@ abstract class MantraDao(
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraTranslationArtifactVersion? {
val artifact = database.mantraArtifactDao().getArtifactById(artifactId) ?: return null
val version = database.mantraArtifactVersionDao()
.getArtifactVersionsByArtifactId(artifactId)
@@ -339,21 +364,12 @@ abstract class MantraDao(
return try {
database.mantraTranslationArtifactVersionDao().upsert(translationVersion)
val translationArtifactVersionInnerEvent = MarmotInnerEvent(
id = translationVersion.id,
publicKey = translationVersion.publicKey,
kind = TranslationArtifactVersionEvent.KIND,
createdAt = translationVersion.createdAt,
tags = translationVersionTemplate.tags,
content = translationVersionTemplate.content,
chatRoomId = translationVersion.chatRoomId,
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(translationVersionTemplate, userPublicKey),
text = "Added ${dialect.name} translation",
marmotInnerEvent = translationArtifactVersionInnerEvent
)
// Mirror the source structure: a translation chapter per chapter and
@@ -372,21 +388,12 @@ abstract class MantraDao(
) ?: return@forEach
database.mantraTranslationChapterDao().upsert(translationChapter)
val translationChapterInnerEvent = MarmotInnerEvent(
id = translationChapter.id,
publicKey = translationChapter.publicKey,
kind = TranslationChapterEvent.KIND,
createdAt = translationChapter.createdAt,
tags = translationChapterTemplate.tags,
content = translationChapterTemplate.content,
chatRoomId = translationChapter.chatRoomId,
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(translationChapterTemplate, userPublicKey),
text = "Prepared ${dialect.name} translation of the chapter ${chapter.name}",
marmotInnerEvent = translationChapterInnerEvent
)
// TODO: Figure out if we seriously need the scaffolding
@@ -427,7 +434,7 @@ abstract class MantraDao(
// }
}
translationArtifactVersionInnerEvent
translationVersion
} catch (error: Throwable) {
logger.e("Failed to add translation for artifact $artifactId", error)
null
@@ -460,30 +467,25 @@ abstract class MantraDao(
return try {
// Replace any existing translation chunk for this source chunk. Its id
// is derived from the (now changed) content, so it becomes a new row —
// drop the old one (and its rumor) to keep one per source chunk.
// drop the old one (and the submission carrying it) to keep one per
// source chunk. The submission is found by what it carries, since its
// own id is the envelope's rather than the chunk's.
database.mantraTranslationChunkDao()
.getTranslationChunksByTranslationChapterId(translationChapterId)
.filter { it.chunkId == chunkId && it.id != translationChunk.id }
.forEach { stale ->
database.mantraTranslationChunkDao().deleteById(stale.id)
database.marmotInnerEventDao().deleteById(stale.id)
database.marmotInnerEventDao().deleteByPayloadEventId(stale.id)
}
database.mantraTranslationChunkDao().upsert(translationChunk)
val translationChunkInnerEvent = MarmotInnerEvent(
id = translationChunk.id,
publicKey = translationChunk.publicKey,
kind = TranslationChunkEvent.KIND,
createdAt = translationChunk.createdAt,
tags = translationChunkTemplate.tags,
content = translationChunkTemplate.content,
chatRoomId = translationChunk.chatRoomId,
)
sendMarmotInnerEvent(
submitToGroup(
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
submitterPublicKey = userPublicKey,
payload = rumorOf(translationChunkTemplate, userPublicKey),
text = "Translated chunk ${translationChunk.index}", // TODO: Use a portion of the translation and name the language
marmotInnerEvent = translationChunkInnerEvent
)
translationChunk
@@ -493,19 +495,6 @@ abstract class MantraDao(
}
}
private suspend fun sendMarmotInnerEvent(
localChatRoom: LocalChatRoom,
text: String,
marmotInnerEvent: MarmotInnerEvent
) {
sendMarmotInnerEvent(
chatRoomId = localChatRoom.chatRoom.id,
userPublicKey = localChatRoom.chatRoom.userPublicKey,
text = text,
marmotInnerEvent = marmotInnerEvent
)
}
private suspend fun sendMarmotInnerEvent(
chatRoomId: String,
userPublicKey: HexKey,
@@ -15,6 +15,25 @@ interface MarmotInnerEventDao {
@Upsert
suspend fun upsert(marmotInnerEvent: MarmotInnerEvent)
/**
* A room's inner events of the given kinds, oldest first.
*
* Used to replay a protocol backlog: a session's messages are stored as they
* decrypt, but one that arrives before the proposal opening its session has
* nowhere to be filed at the time.
*/
@Query("SELECT * FROM MarmotInnerEvent WHERE chatRoomId = :chatRoomId AND kind IN (:kinds) ORDER BY createdAt ASC")
suspend fun getByChatRoomAndKinds(chatRoomId: String, kinds: List<Int>): List<MarmotInnerEvent>
@Query("DELETE FROM MarmotInnerEvent WHERE id = :id")
suspend fun deleteById(id: String)
/**
* Drop the submissions carrying [payloadEventId].
*
* A submission's id is the envelope's, not the payload's, so a superseded
* nip30303 event cannot be un-queued by its own id.
*/
@Query("DELETE FROM MarmotInnerEvent WHERE payloadEventId = :payloadEventId")
suspend fun deleteByPayloadEventId(payloadEventId: String)
}
@@ -31,6 +31,9 @@ import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventI
import press.mantra.compose.extensions.toHex
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.managers.MlsGroupCache
import press.mantra.compose.managers.MarmotInboundManager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
@@ -43,6 +46,7 @@ import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -385,9 +389,21 @@ abstract class NostrDao(
val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)
if (localChatRoom != null) {
val mlsGroup = localChatRoom.chatRoom.toMlsGroup()
if (mlsGroup != null) {
// Through the cache rather than rebuilt here, so the secret
// tree's skipped-generation keys survive from one message to
// the next. Two events published in the same instant arrive in
// whatever order the relay feels like, and rebuilding between
// them loses the earlier one for good -- see MlsGroupCache.
val handled = MlsGroupCache.withGroup(
chatRoomId = chatRoomId,
storedStateHex = localChatRoom.chatRoom.mlsGroupState,
build = { localChatRoom.chatRoom.toMlsGroup() },
save = { stateHex ->
database.chatRoomDao().upsert(
localChatRoom.chatRoom.copy(mlsGroupState = stateHex)
)
}
) { mlsGroup ->
val memberPubkeys = mlsGroup.members().mapNotNull { (leafIndex, leafNode) ->
val pubkey = when (val cred = leafNode.credential) {
@@ -434,12 +450,6 @@ abstract class NostrDao(
}
}
// Save the mls chatRoom state...
database.chatRoomDao().upsert(
localChatRoom.chatRoom.copy(
mlsGroupState = mlsGroup.saveState().encodeTls().toHex()
)
)
ChatMessage.fromGroupEventResult(
database = database,
activeKeyPair = activeKeyPair,
@@ -451,11 +461,34 @@ abstract class NostrDao(
chatMessage
)
}
// A FROST signing message for this group. Driven from
// here rather than from ChatMessage because the
// manager needs the room to publish its own replies
// into, and because it writes its transcript lines
// itself. The manager is idempotent, so a redelivered
// message re-runs a step it has already taken.
if (groupEventResult is GroupEventResult.ApplicationMessage) {
Event.fromJsonOrNull(groupEventResult.innerEventJson)
?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) }
?.let { innerEvent ->
FrostSigningManager.processSigningPayload(
database = database,
localChatRoom = localChatRoom,
innerEvent = innerEvent,
userPublicKey = activeKeyPair.pubKey.toHex()
)
}
}
}
} else {
throw MarmotNotMemberOfChatGroupException("We are not a member of the chat room ${localChatRoom.chatRoom.id}")
}
} else {
}
// Null means the room has no usable group state, which is what
// a failed toMlsGroup() meant before the cache existed.
if (handled == null) {
throw MarmotMissingNostrGroupDataExtension("Couldn't find chatRoom for $nostrEvent")
}
} else {
@@ -17,11 +17,13 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionContributorListEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
@@ -178,6 +180,65 @@ data class ChatMessage(
* A finished ceremony is the exception, and has no actor: the group ends up
* with a key, nobody hands it to them.
*/
/**
* A FROST signing session, as lines in the group's chat.
*
* The same shape as the ceremony's, and for the same reason: signing with
* the group's key is otherwise a black box, and a session stalled on one
* member gives no way to see whose door to knock on.
*/
const val TYPE_FROST_STARTED = "frostStarted"
const val TYPE_FROST_NONCE = "frostNonce"
const val TYPE_FROST_SIGNER_SET = "frostSignerSet"
const val TYPE_FROST_PARTIAL_SIGNATURE = "frostPartialSignature"
const val TYPE_FROST_SIGNATURE = "frostSignature"
const val TYPE_FROST_COMPLETE = "frostComplete"
const val TYPE_FROST_FAILED = "frostFailed"
/** Addressed to the reader rather than said by anyone -- see [DKG_REQUEST_TYPES]. */
const val TYPE_FROST_APPROVAL_NEEDED = "frostApprovalNeeded"
/**
* Answering the request publishes this device's nonce, not its signature:
* approving is agreeing to take part, and the coordinator may then pick a
* quorum that does not include this member. Keying the answer on the
* partial signature would leave a member who agreed, and was not needed,
* looking like they never replied.
*/
val FROST_REQUEST_FULFILMENTS = mapOf(
TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE,
)
/** The signing lines that ask rather than report. */
val FROST_REQUEST_TYPES = setOf(TYPE_FROST_APPROVAL_NEEDED)
/** Every signing line, for rendering them as system lines rather than bubbles. */
val FROST_TYPES = setOf(
TYPE_FROST_STARTED,
TYPE_FROST_NONCE,
TYPE_FROST_SIGNER_SET,
TYPE_FROST_PARTIAL_SIGNATURE,
TYPE_FROST_SIGNATURE,
TYPE_FROST_COMPLETE,
TYPE_FROST_FAILED,
TYPE_FROST_APPROVAL_NEEDED,
)
/**
* The signing lines somebody did, as opposed to ones that simply happened.
* Their content is written as a predicate for the actor's name to be read
* in front of. A finished signature has no actor: the group ends up with
* one, nobody hands it to them.
*/
val FROST_AUTHORED_TYPES = setOf(
TYPE_FROST_STARTED,
TYPE_FROST_NONCE,
TYPE_FROST_SIGNER_SET,
TYPE_FROST_PARTIAL_SIGNATURE,
TYPE_FROST_SIGNATURE,
TYPE_FROST_FAILED,
)
val DKG_AUTHORED_TYPES = setOf(
TYPE_DKG_STARTED,
TYPE_DKG_HOST_KEY,
@@ -199,6 +260,25 @@ data class ChatMessage(
val event = Event.fromJsonOrNull(groupEventResult.innerEventJson)
?: throw MarmotUnprocessableInnerEventException("Can't process ${groupEventResult.innerEventJson}")
// A submission is an envelope: the nip30303 event it delivers
// is in its content, written by whoever wrote it. Everything
// else on the wire is the nip30303 event itself. Either way the
// row on disk is the outer event -- that is what the group sent
// and what the chat line is attributed to.
val submission = if (event.kind == SubmissionEvent.KIND) {
SubmissionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
)
} else {
null
}
val payload = submission?.payload()
database.marmotInnerEventDao().upsert(
MarmotInnerEvent(
id = event.id,
@@ -208,296 +288,37 @@ data class ChatMessage(
content = event.content,
chatRoomId = groupEventResult.groupId,
kind = event.kind,
payloadEventId = payload?.id,
createdAt = Instant.fromEpochSeconds(event.createdAt)
)
)
when (event.kind) {
ChatEvent.KIND -> {
ChatMessage(
giftWrapPayloadId = null,
messageType = "message",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = event.content, // TODO: Figure out what to do here...
)
}
ArtifactEvent.KIND -> {
MantraArtifact.fromArtifactEvent(
artifactEvent = ArtifactEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraArtifact ->
database.mantraArtifactDao().upsert(
mantraArtifact.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifact",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraArtifact.name} to artifacts"
)
}
}
ArtifactVersionEvent.KIND -> {
MantraArtifactVersion.fromArtifactVersionEvent(
artifactVersionEvent = ArtifactVersionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraArtifactVersion ->
database.mantraArtifactVersionDao().upsert(
mantraArtifactVersion.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifactVersion",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
)
}
}
ChapterEvent.KIND -> {
MantraChapter.fromChapterEvent(
chapterEvent = ChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraChapter ->
database.mantraChapterDao().upsert(
mantraChapter.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "chapter",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name...
)
}
}
ChunkEvent.KIND -> {
MantraChunk.fromChunkEvent(
chunkEvent = ChunkEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraChunk ->
database.mantraChunkDao().upsert(
mantraChunk.copy(
marmotGroupEventId = groupEvent.id,
)
)
// TODO: Chunks might be too noisy to show in chat...
}
null
}
DialectEvent.KIND -> {
MantraDialect.fromDialectEvent(
dialectEvent = DialectEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraDialect ->
database.mantraDialectDao().upsert(
mantraDialect.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "dialect",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name...
)
}
}
TranslationArtifactVersionEvent.KIND -> {
MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent(
translationArtifactVersionEvent = TranslationArtifactVersionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraTranslationArtifactVersion ->
database.mantraTranslationArtifactVersionDao().upsert(
mantraTranslationArtifactVersion.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translationArtifactVersion",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name...
)
}
}
TranslationArtifactVersionContributorListEvent.KIND -> {
// TODO: Consume contributors...
null
}
TranslationChapterEvent.KIND -> {
MantraTranslationChapter.fromTranslationChapterEvent(
translationChapterEvent = TranslationChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraTranslationChapter ->
database.mantraTranslationChapterDao().upsert(
mantraTranslationChapter.copy(
marmotGroupEventId = groupEvent.id,
)
)
// TODO: translation chapter might be too noisy for chat updates
}
null
}
TranslationChunkEvent.KIND -> {
MantraTranslationChunk.fromTranslationChunkEvent(
translationChunkEvent = TranslationChunkEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraTranslationChunk ->
database.mantraTranslationChunkDao().upsert(
mantraTranslationChunk.copy(
marmotGroupEventId = groupEvent.id,
)
)
}
null
}
TranslationContributorListEvent.KIND -> {
// TODO: Consume contributors...
null
}
TranslationEvent.KIND -> {
MantraTranslation.fromTranslationEvent(
translationEvent = TranslationEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupEventResult.groupId,
)?.let { mantraTranslation ->
database.mantraTranslationDao().upsert(
mantraTranslation.copy(
marmotGroupEventId = groupEvent.id,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translation",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text
)
}
}
else -> {
ChatMessage(
giftWrapPayloadId = null,
messageType = "unsupported",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = groupEvent.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(groupEvent.createdAt),
content = groupEventResult.innerEventJson, // TODO: Figure out what to do here...
)
}
// A submission whose payload will not parse, or which carries
// another submission, is kept but not applied: there is nothing
// here we can turn into a row.
if (submission != null && (payload == null || payload.kind == SubmissionEvent.KIND)) {
ChatMessage(
giftWrapPayloadId = null,
messageType = "unsupported",
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
chatRoomId = groupEventResult.groupId,
createdAt = Instant.fromEpochSeconds(event.createdAt),
content = event.content,
)
} else {
applyInnerEvent(
database = database,
groupId = groupEventResult.groupId,
event = payload ?: event,
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
createdAt = Instant.fromEpochSeconds(event.createdAt),
)
}
}
is GroupEventResult.CommitPending -> {
@@ -581,5 +402,323 @@ data class ChatMessage(
}
}
}
/**
* Apply one nip30303 [event] the group delivered, and describe it for
* the transcript.
*
* [event] is what is being applied; the other parameters are how it
* arrived. They come apart for submissions: the payload is written by
* whoever wrote it -- possibly nobody in this group -- while
* [marmotInnerEventId], [senderPublicKey] and [createdAt] all belong to
* the envelope a member actually sent. Entity rows take their author
* from [event], so the chat line says who added it and the row says who
* wrote it.
*/
internal suspend fun applyInnerEvent(
database: MantraDatabase,
groupId: String,
event: Event,
marmotGroupEventId: HexKey?,
marmotInnerEventId: HexKey?,
senderPublicKey: HexKey,
isUserMessage: Boolean,
createdAt: Instant,
): ChatMessage? {
return when (event.kind) {
ChatEvent.KIND -> {
ChatMessage(
giftWrapPayloadId = null,
messageType = "message",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = event.content, // TODO: Figure out what to do here...
)
}
ArtifactEvent.KIND -> {
MantraArtifact.fromArtifactEvent(
artifactEvent = ArtifactEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig,
),
chatRoomId = groupId,
)?.let { mantraArtifact ->
database.mantraArtifactDao().upsert(
mantraArtifact.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifact",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraArtifact.name} to artifacts"
)
}
}
ArtifactVersionEvent.KIND -> {
MantraArtifactVersion.fromArtifactVersionEvent(
artifactVersionEvent = ArtifactVersionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraArtifactVersion ->
database.mantraArtifactVersionDao().upsert(
mantraArtifactVersion.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifactVersion",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
)
}
}
ChapterEvent.KIND -> {
MantraChapter.fromChapterEvent(
chapterEvent = ChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraChapter ->
database.mantraChapterDao().upsert(
mantraChapter.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "chapter",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name...
)
}
}
ChunkEvent.KIND -> {
MantraChunk.fromChunkEvent(
chunkEvent = ChunkEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraChunk ->
database.mantraChunkDao().upsert(
mantraChunk.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
// TODO: Chunks might be too noisy to show in chat...
}
null
}
DialectEvent.KIND -> {
MantraDialect.fromDialectEvent(
dialectEvent = DialectEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraDialect ->
database.mantraDialectDao().upsert(
mantraDialect.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "dialect",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name...
)
}
}
TranslationArtifactVersionEvent.KIND -> {
MantraTranslationArtifactVersion.fromTranslationArtifactVersionEvent(
translationArtifactVersionEvent = TranslationArtifactVersionEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraTranslationArtifactVersion ->
database.mantraTranslationArtifactVersionDao().upsert(
mantraTranslationArtifactVersion.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translationArtifactVersion",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name...
)
}
}
TranslationArtifactVersionContributorListEvent.KIND -> {
// TODO: Consume contributors...
null
}
TranslationChapterEvent.KIND -> {
MantraTranslationChapter.fromTranslationChapterEvent(
translationChapterEvent = TranslationChapterEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraTranslationChapter ->
database.mantraTranslationChapterDao().upsert(
mantraTranslationChapter.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
// TODO: translation chapter might be too noisy for chat updates
}
null
}
TranslationChunkEvent.KIND -> {
MantraTranslationChunk.fromTranslationChunkEvent(
translationChunkEvent = TranslationChunkEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraTranslationChunk ->
database.mantraTranslationChunkDao().upsert(
mantraTranslationChunk.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
}
null
}
TranslationContributorListEvent.KIND -> {
// TODO: Consume contributors...
null
}
TranslationEvent.KIND -> {
MantraTranslation.fromTranslationEvent(
translationEvent = TranslationEvent(
id = event.id,
pubKey = event.pubKey,
createdAt = event.createdAt,
tags = event.tags,
content = event.content,
sig = event.sig
),
chatRoomId = groupId,
)?.let { mantraTranslation ->
database.mantraTranslationDao().upsert(
mantraTranslation.copy(
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translation",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text
)
}
}
// A signing session's own protocol messages. FrostSigningManager
// applies them and writes its own transcript lines naming who did
// what, so an "unsupported" row here would be a second, worse
// account of the same thing.
in FrostSigningEvents.ALL -> null
else -> {
ChatMessage(
giftWrapPayloadId = null,
messageType = "unsupported",
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = event.toJson(),
)
}
}
}
}
}
@@ -9,6 +9,9 @@ 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 com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.PublicKey
import kotlin.time.Clock
import kotlin.time.Instant
@@ -82,6 +85,17 @@ data class DkgSession(
/** Result, secret: this device's FROST secret share, hex. */
val secretShare: HexKey? = null,
/**
* Result: every participant's public share, comma-separated hex, in
* participant order.
*
* Not secret, and not needed to finish the ceremony -- kept because signing
* is what the key is for and FROST validates each signer's secret share
* against its public one. Null on a ceremony that completed before this
* column existed; signing still works there, without that check.
*/
val publicShares: String? = null,
/** Result: recovery data, to be backed up alongside the host key. */
val recoveryData: HexKey? = null,
@@ -125,4 +139,11 @@ data class DkgSession(
override val savedAt: Instant = createdAt,
): TimestampedEntity, LocalStoreEntity {
fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey
/** The participants' public shares in participant order, or null if unrecorded. */
fun publicShareList(): List<PublicKey>? = publicShares
?.split(",")
?.mapNotNull { hex -> hex.trim().takeIf { it.isNotEmpty() } }
?.map { PublicKey(ByteVector(it.hexToByteArray())) }
?.takeIf { it.isNotEmpty() }
}
@@ -0,0 +1,52 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlin.time.Clock
import kotlin.time.Instant
/**
* One signing message from one member, keyed so a duplicate delivery overwrites
* rather than accumulates — relays redeliver, and feeding the same nonce in
* twice would make the coordinator's signer set the wrong length.
*
* Covers the nonce and the partial signature; the coordinator's own broadcasts
* live on [FrostSigningSession] because there is only ever one of each.
*
* Keyed on `(sessionId, signerPublicKey, kind)`, which also means a member
* cannot replace their own nonce once the coordinator has aggregated it — a
* second nonce from the same signer overwrites the first, and the aggregate
* built from it simply stops matching. The session's own write-once rule on the
* aggregate is what makes that a stalled session rather than a leaked share.
*/
@Entity(
primaryKeys = ["sessionId", "signerPublicKey", "kind"],
foreignKeys = [
ForeignKey(
entity = FrostSigningSession::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
Index("sessionId"),
],
)
data class FrostSignerMessage(
val sessionId: String,
/** The member's nostr public key — who sent it. */
val signerPublicKey: HexKey,
/** One of the `FrostSigningEvents` kinds. */
val kind: Kind,
/** Hex of the protocol bytes: a public nonce, or a partial signature. */
val payload: HexKey,
val createdAt: Instant = Clock.System.now(),
)
@@ -0,0 +1,139 @@
package press.mantra.compose.database.model
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.time.Clock
import kotlin.time.Instant
import press.mantra.compose.database.model.traits.LocalStoreEntity
import press.mantra.compose.database.model.traits.TimestampedEntity
import press.mantra.compose.database.model.types.FrostSigningStage
/**
* One FROST signing session, as this device sees it.
*
* Like [DkgSession] this stores *inputs* rather than protocol state, so a
* session survives the app being killed halfway through: every step is
* recomputed from what is on the row. Unlike a DKG that choice is not merely
* convenient here, it is forced -- `fr.acinq.bitcoin.crypto.frost.SecretNonce`
* cannot be serialised and refuses to be used twice, by design.
*
* ### The nonce, and why one session means one message
*
* [nonceRandom] is secret, and regenerating this device's nonce from it is safe
* for exactly one reason: a session signs one message and can never be made to
* sign another. `SecretNonce.generate` mixes the message in, so the same
* randomness under a different message would be a different nonce -- but the
* same randomness under the same message with two *different* aggregated nonces
* would produce two partial signatures over one secret nonce, which is how a
* secret share is extracted.
*
* Two rules keep that impossible, and both are load-bearing:
*
* - [eventId] is written when the session is created and a proposal that
* disagrees with it is rejected rather than applied.
* - [aggregatedNonce] and [signerIds] are written once. A second, different
* signer set for the same session is ignored, not honoured.
*/
@Entity(
foreignKeys = [
ForeignKey(
entity = ChatRoom::class,
parentColumns = ["id"],
childColumns = ["chatRoomId"],
onDelete = ForeignKey.CASCADE,
)
],
indices = [
Index("chatRoomId"),
Index("dkgSessionId"),
],
)
data class FrostSigningSession(
/** Minted by the proposer, carried on every message as `frost_session`. */
@PrimaryKey
val id: String,
val chatRoomId: String,
/** The member who proposed the signature, who also aggregates for it. */
val coordinatorPublicKey: HexKey,
/** Whose device this row belongs to, for multi-account support. */
val userPublicKey: HexKey,
/** The ceremony whose key this signs with — a group may hold more than one. */
val dkgSessionId: String,
/** The `t` of the t-of-n: how many partial signatures make a signature. */
val threshold: Int,
/** The `n` the key was generated for. FROST needs it to place signer ids. */
val participantCount: Int,
/** This device's FROST id: its index in the ceremony's participant order. */
val signerId: Int,
val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES,
/**
* The unsigned event, as JSON. Kept whole so a member can be shown what
* they are being asked to sign rather than a hash of it.
*/
val unsignedEventJson: String,
/**
* The event id, which is the 32 bytes actually signed.
*
* Recomputed from the event's own fields on arrival, never taken from the
* proposal. It pins the session to one message -- see the class note on why
* that is what makes reusing [nonceRandom] safe.
*/
val eventId: HexKey,
/** Secret. 32 bytes of fresh randomness, the seed for this device's nonce. */
val nonceRandom: HexKey,
/** The coordinator's `AggregatedNonce` once it arrives, hex. Written once. */
val aggregatedNonce: HexKey? = null,
/** The chosen signers' FROST ids in aggregation order, comma separated. Written once. */
val signerIds: String? = null,
/** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */
val signature: HexKey? = null,
val failureReason: String? = null,
/**
* When this device's owner agreed to sign, and with it to everything the
* session does on their behalf. Null until they do, and nothing of theirs
* goes out before it is set.
*
* One gate rather than the DKG's three. What a signer is consenting to is
* the event, and the event is fixed before they are asked: the second round
* puts no new question to them, so asking again would be asking the same
* question twice about a decision already made.
*/
val signApprovedAt: Instant? = null,
/** Whether the chat line asking for that approval has been written. */
val approvalRequestedAt: Instant? = null,
override val createdAt: Instant = Clock.System.now(),
override val updatedAt: Instant = createdAt,
override val savedAt: Instant = createdAt,
): TimestampedEntity, LocalStoreEntity {
fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey
/** The chosen signers, or null while the coordinator has yet to choose. */
fun signerIdList(): List<Int>? = signerIds
?.split(",")
?.mapNotNull { it.trim().toIntOrNull() }
?.takeIf { it.isNotEmpty() }
/** Whether this device was picked to sign. A t-of-n key does not need everyone. */
fun isSigner(): Boolean = signerIdList()?.contains(signerId) ?: false
}
@@ -64,6 +64,15 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
val content: String,
val quotedEventId: String? = null,
/**
* For a SubmissionEvent rumor, the id of the nip30303 event it carries.
*
* The submission's own id is derived from the envelope, so it is the only
* handle the queue has on what is actually being submitted. Null for every
* other kind, where the row *is* the event.
*/
val payloadEventId: HexKey? = null,
/**
* Associated MarmotGroupEvent
*/
@@ -117,6 +126,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
if (!tags.contentDeepEquals(other.tags)) return false
if (content != other.content) return false
if (quotedEventId != other.quotedEventId) return false
if (payloadEventId != other.payloadEventId) return false
if (marmotGroupEventId != other.marmotGroupEventId) return false
if (createdAt != other.createdAt) return false
if (updatedAt != other.updatedAt) return false
@@ -136,6 +146,7 @@ data class MarmotInnerEvent( // TODO: Rename this to GiftWrapPayload...
result = 31 * result + tags.contentDeepHashCode()
result = 31 * result + content.hashCode()
result = 31 * result + (quotedEventId?.hashCode() ?: 0)
result = 31 * result + (payloadEventId?.hashCode() ?: 0)
result = 31 * result + (marmotGroupEventId?.hashCode() ?: 0)
result = 31 * result + createdAt.hashCode()
result = 31 * result + updatedAt.hashCode()
@@ -0,0 +1,25 @@
package press.mantra.compose.database.model.types
/**
* How far a FROST signing session has got, from the point of view of the device
* holding the row. Coordinator and signers move through the same ladder; the
* coordinator simply has extra work to do at [COLLECTING_NONCES] and
* [COLLECTING_PARTIAL_SIGNATURES].
*
* Declaration order is the ladder: `FrostSigningManager` compares ordinals to
* keep the label moving forwards when messages arrive out of order, so the
* collecting stages must stay in the order the session runs them.
*/
enum class FrostSigningStage {
/** Proposal seen; waiting for enough signers to offer a nonce. */
COLLECTING_NONCES,
/** The signer set is fixed; waiting on their partial signatures. */
COLLECTING_PARTIAL_SIGNATURES,
/** Aggregated and verified. The event is signed. */
COMPLETE,
/** Abandoned. See `FrostSigningSession.failureReason`. */
FAILED
}
@@ -0,0 +1,101 @@
package press.mantra.compose.database.repository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.repository.FrostSigningRepository
class DatabaseFrostSigningRepository(
private val database: MantraDatabase,
private val scope: CoroutineScope
): FrostSigningRepository {
private val logger = Logger.withTag(TAG)
override fun observeSessionById(sessionId: String): Flow<FrostSigningSession?> =
database.frostSigningSessionDao().observeSessionById(sessionId)
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
database.frostSigningSessionDao().observeMessages(sessionId)
override suspend fun getSessionById(sessionId: String): FrostSigningSession? =
database.frostSigningSessionDao().getSessionById(sessionId)
override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? {
val sessions = database.frostSigningSessionDao().getSessionsForChatRoom(chatRoomId)
return sessions.firstOrNull {
it.stage != FrostSigningStage.COMPLETE && it.stage != FrostSigningStage.FAILED
} ?: sessions.firstOrNull()
}
override suspend fun canSign(chatRoomId: String): Boolean =
FrostSigningManager.canSign(database, chatRoomId)
override suspend fun proposeSigning(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
kind: Kind,
tags: Array<Array<String>>,
content: String
): FrostSigningSession? = try {
FrostSigningManager.proposeSigning(
database = database,
localChatRoom = localChatRoom,
userPublicKey = userPublicKey,
kind = kind,
tags = tags,
content = content
)
} catch (e: Throwable) {
// Proposing throws when the group has no key or this device was not in the
// ceremony. Both are states the UI is supposed to have checked for, so this
// is a null the caller reports rather than a crash.
logger.e("Error proposing a signature in ${localChatRoom.chatRoom.id}", e)
null
}
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) {
try {
FrostSigningManager.approve(
database = database,
localChatRoom = localChatRoom,
sessionId = sessionId
)
} catch (e: Throwable) {
// The session fails itself and tells the group; swallowing here keeps a
// protocol fault from taking the screen down with it.
logger.e("Error approving signing session $sessionId", e)
}
}
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) {
try {
FrostSigningManager.decline(
database = database,
localChatRoom = localChatRoom,
sessionId = sessionId
)
} catch (e: Throwable) {
logger.e("Error declining signing session $sessionId", e)
}
}
override fun signedEvent(session: FrostSigningSession): Event? =
FrostSigningManager.signedEvent(session)
companion object {
private const val TAG = "DatabaseFrostSigningRepository"
}
}
@@ -44,7 +44,7 @@ class DatabaseMantraRepository(
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraTranslationArtifactVersion? {
return database.mantraDao().addTranslationArtifactVersion(
artifactId = artifactId,
dialectId = dialectId,
@@ -99,7 +99,7 @@ class DatabaseMantraRepository(
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraChapter? {
return database.mantraDao().addChapter(
artifactId = artifactId,
name = name,
@@ -121,7 +121,7 @@ class DatabaseMantraRepository(
country: String,
language: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraDialect? {
return database.mantraDao().addDialect(
localChatRoom = localChatRoom,
name = name,
@@ -140,7 +140,7 @@ class DatabaseMantraRepository(
userPublicKey: HexKey,
visibility: String,
license: String,
): MarmotInnerEvent? {
): MantraArtifact? {
return database.mantraDao().addArtifact(
localChatRoom = localChatRoom,
name = name,
@@ -158,7 +158,7 @@ class DatabaseMantraRepository(
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? {
): MantraArtifactVersion? {
return database.mantraDao().addArtifactVersion(
localChatRoom = localChatRoom,
artifactId = artifactId,
@@ -574,6 +574,12 @@ object ChillDkgRitualManager {
stage = DkgRitualStage.COMPLETE,
thresholdPublicKey = output.thresholdPublicKey?.value?.toHex(),
secretShare = output.secretShare?.value?.toHex(),
// Kept for signing, which needs every participant's public
// share to place and check the signers. In participant order,
// the same order the ids are derived from.
publicShares = output.publicShares
.joinToString(",") { share -> share.value.toHex() }
.takeIf { output.publicShares.isNotEmpty() },
recoveryData = output.recovery?.toHex()
)
}
@@ -0,0 +1,152 @@
package press.mantra.compose.managers
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import press.mantra.compose.extensions.toHex
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Keeps a room's [MlsGroup] alive between messages instead of rebuilding it
* from the stored state every time.
*
* ### The bug this exists for
*
* MLS is specified to tolerate out-of-order delivery within an epoch: a
* receiver that gets generation N+1 before N derives and caches the key for N
* so the older message can still be read when it turns up. Quartz's
* `SecretTree` does exactly that, in a private `skippedKeys` map.
*
* `SecretTree.exportSenderStates()` does not include that map, so
* `MlsGroup.saveState()` does not carry it. Rebuilding the group from stored
* state therefore throws the skipped keys away, and a message for a generation
* the ratchet has already passed fails
* `require(generation >= state.applicationGeneration)` and is dropped. There is
* no recovering it afterwards: the key is gone and the sender will not resend.
*
* Nostr relays offer no ordering whatsoever, so this is not an edge case. Two
* events published in the same second race, and exactly one survives — which is
* how a signing session's proposal was lost while the nonce sent immediately
* behind it arrived fine.
*
* ### What this fixes, and what it does not
*
* Holding the instance means `skippedKeys` survives for as long as the process
* does and nothing else writes the room's state. That covers the case that
* actually bites — a burst of messages arriving in one sync — because they are
* decrypted one after another against the same tree.
*
* It does not survive a restart, and it does not survive another writer, so
* reordering across app launches still loses messages. The real fix is for
* `exportSenderStates` to carry the skipped keys; see
* `docs/mls-skipped-keys.md`.
*
* ### Staleness
*
* The group is only reused when the stored state is still exactly what this
* cache last wrote. Anything else that saves a room's state — sending a message
* advances the sender ratchet and saves, so does adding a member — changes the
* hex, and the next read rebuilds rather than carrying on from a group that has
* been overtaken. Losing the skipped keys there is the same behaviour as
* before this existed, so the fallback is never worse than not caching.
*/
object MlsGroupCache {
private val cache = LiveInstanceCache<MlsGroup> { it.saveState().encodeTls().toHex() }
/**
* Runs [block] against the room's live group, then stores whatever state it
* left behind.
*
* [storedStateHex] is the room's state as the database currently has it, and
* [build] turns it into a group. [save] is handed the state to persist.
*
* Returns null without calling [block] when the room has no usable group
* state, which is the same thing a failed `toMlsGroup()` meant before.
*/
suspend fun <T> withGroup(
chatRoomId: String,
storedStateHex: String?,
build: () -> MlsGroup?,
save: suspend (String) -> Unit,
block: suspend (MlsGroup) -> T,
): T? = cache.withInstance(
key = chatRoomId,
storedState = storedStateHex,
build = build,
save = save,
block = block
)
}
/**
* One live instance per key, reused only while the stored state is still the one
* this cache last wrote.
*
* Split out from [MlsGroupCache] so the decision it makes can be tested without
* standing up an MLS group. That decision is the whole safety argument: reuse
* when nothing else has written, rebuild when something has, and never carry on
* with an instance whose last use failed part-way through.
*/
internal class LiveInstanceCache<T : Any>(
/** The persisted form of an instance, for spotting another writer. */
private val stateOf: (T) -> String,
) {
private val logger = Logger.withTag("LiveInstanceCache")
private class Entry<T>(val instance: T, val state: String)
private val entries = mutableMapOf<String, Entry<T>>()
/**
* Serialises use of one key's instance.
*
* The instance is mutable and [block] advances it, so two callers running at
* once would corrupt it. One lock per key rather than one overall, so a busy
* key cannot hold up a quiet one.
*
* Held across [block], which may touch the database. Safe here because a
* caller only ever takes this lock while it is already running -- it never
* waits on a resource the holder is itself waiting for.
*/
private val locks = mutableMapOf<String, Mutex>()
private val locksGuard = Mutex()
private suspend fun lockFor(key: String): Mutex =
locksGuard.withLock { locks.getOrPut(key) { Mutex() } }
suspend fun <R> withInstance(
key: String,
storedState: String?,
build: () -> T?,
save: suspend (String) -> Unit,
block: suspend (T) -> R,
): R? = lockFor(key).withLock {
val cached = entries[key]
val instance = if (cached != null && cached.state == storedState) {
cached.instance
} else {
if (cached != null) {
logger.d("$key was written elsewhere; rebuilding")
}
// Dropped before the block runs, so a build that fails does not leave
// the old instance behind to be picked up by the next caller.
entries.remove(key)
build() ?: return@withLock null
}
// Deliberately not in a finally: an instance whose use threw part-way is
// in an unknown state, and the next caller should rebuild from whatever
// was last persisted rather than carry on with it.
val result = block(instance)
val state = stateOf(instance)
save(state)
entries[key] = Entry(instance = instance, state = state)
result
}
/** How many instances are held. For tests. */
internal fun size(): Int = entries.size
}
@@ -0,0 +1,102 @@
package press.mantra.compose.nostr.frost
import com.vitorpamplona.quartz.nip01Core.core.Kind
import press.mantra.compose.nostr.frost.tags.FrostKeyTag
import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag
import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
/**
* The nostr kinds a FROST signing session is carried on.
*
* These are **rumor** kinds: they only ever exist inside a Marmot group event,
* MLS-encrypted to the group and then wrapped again under the group's exporter
* secret, so no relay sees them and the replaceable semantics normally implied
* by the 3xxxx range never apply.
*
* Who talks to whom, in order:
*
* ```
* proposer --[ 30320 proposal ]-> everyone the unsigned event to sign
* signer --[ 30321 nonce ]-> everyone this device's public nonce
* proposer --[ 30322 signer set ]-> everyone who is signing, and their aggregated nonce
* signer --[ 30323 partial ]-> everyone this device's partial signature
* proposer --[ 30324 signature ]-> everyone the finished 64-byte signature
* anyone --[ 30325 failure ]-> everyone abandon + blame
* ```
*
* ### Why 3032x
*
* These share the inner-event space with the nip30303 document kinds, which run
* 30300 up to [press.mantra.compose.nostr.nip30303.SubmissionEvent] at 30312 --
* the same space, because both are Marmot inner events and both are dispatched
* on kind by the same inbound path. Starting at 30320 leaves that family room to
* grow into.
*
* The DKG's 30310-30316 look like a clash and are not: those exist only inside
* NIP-17 gift wraps, and nothing reads a kind across both transports. It is
* worth knowing that the numbers already overlap there -- the DKG's proposal and
* host-key kinds sit on 30310 and 30311 alongside two nip30303 kinds, and its
* round-1 kind is 30312, alongside SubmissionEvent -- because that separation is
* an accident of routing rather than a decision, and the next family added
* should not rely on it.
*/
object FrostSigningEvents {
/**
* Opens a session. Content is the unsigned nostr event, as JSON; the key to
* sign with is named in [FrostKeyTag].
*/
val PROPOSAL: Kind = 30320
/** A signer's `IndividualNonce`, hex encoded. */
val NONCE: Kind = 30321
/**
* The coordinator's chosen signers, in [FrostSignerIdsTag], with their
* `AggregatedNonce` as the content, hex encoded.
*/
val SIGNER_SET: Kind = 30322
/** A signer's 32-byte partial signature, hex encoded. */
val PARTIAL_SIGNATURE: Kind = 30323
/** The finished 64-byte BIP-340 signature over the event id, hex encoded. */
val SIGNATURE: Kind = 30324
/** Session abandoned. Content is the reason, for showing to the group. */
val FAILURE: Kind = 30325
/** Every kind above, for filtering inbound payloads in one check. */
val ALL: Set<Kind> = setOf(
PROPOSAL,
NONCE,
SIGNER_SET,
PARTIAL_SIGNATURE,
SIGNATURE,
FAILURE
)
fun isFrostSigningKind(kind: Kind): Boolean = kind in ALL
/**
* Tags for a signing message. The session id is on every kind so a message
* from an abandoned attempt can be dropped rather than mixed in.
*/
fun assembleTags(
sessionId: String,
dkgSessionId: String? = null,
signerIds: List<Int>? = null
): Array<Array<String>> = buildList {
add(FrostSessionIdTag.assemble(sessionId))
dkgSessionId?.let { add(FrostKeyTag.assemble(it)) }
signerIds?.let { add(FrostSignerIdsTag.assemble(it)) }
}.toTypedArray()
fun parseSessionId(tags: Array<Array<String>>): String? =
tags.firstNotNullOfOrNull(FrostSessionIdTag::parse)?.sessionId
fun parseKey(tags: Array<Array<String>>): String? =
tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId
fun parseSignerIds(tags: Array<Array<String>>): List<Int>? =
tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds
}
@@ -0,0 +1,36 @@
package press.mantra.compose.nostr.frost.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Which key the group is being asked to sign with, named by the ceremony that
* created it.
*
* A group can hold more than one shared key -- a ceremony is re-runnable, and
* a member leaving is a reason to run another -- and a signer holds a
* different secret share under each. Signing with the share from the wrong
* ceremony produces a partial signature that cannot aggregate, so the session
* says which one from the start rather than leaving each device to guess at
* its most recent.
*/
class FrostKeyTag(
val dkgSessionId: String,
) {
fun toTagArray() = assemble(dkgSessionId = dkgSessionId)
companion object {
const val TAG_NAME = "frost_key"
fun parse(tag: Array<String>): FrostKeyTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return FrostKeyTag(dkgSessionId = tag[1])
}
fun assemble(dkgSessionId: String): Array<String> = arrayOf(TAG_NAME, dkgSessionId)
fun assemble(frostKeyTag: FrostKeyTag) = assemble(dkgSessionId = frostKeyTag.dkgSessionId)
}
}
@@ -0,0 +1,36 @@
package press.mantra.compose.nostr.frost.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Binds a signing message to one session.
*
* A group signs more than once with the same key, and an abandoned attempt at
* signing one event must never have its messages fed into a live attempt at
* another. That is stricter here than it is for a DKG: every signer's secret
* nonce is derived per session, so two sessions sharing an id would be two
* different messages signed under one nonce -- which is how a secret share
* leaks.
*/
class FrostSessionIdTag(
val sessionId: String,
) {
fun toTagArray() = assemble(sessionId = sessionId)
companion object {
const val TAG_NAME = "frost_session"
fun parse(tag: Array<String>): FrostSessionIdTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return FrostSessionIdTag(sessionId = tag[1])
}
fun assemble(sessionId: String): Array<String> = arrayOf(TAG_NAME, sessionId)
fun assemble(frostSessionIdTag: FrostSessionIdTag) =
assemble(sessionId = frostSessionIdTag.sessionId)
}
}
@@ -0,0 +1,42 @@
package press.mantra.compose.nostr.frost.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The participants the coordinator chose to sign with, as their FROST ids, in
* the order their nonces were aggregated.
*
* A t-of-n key does not need everyone, so somebody has to pick which t, and
* only the coordinator sees every nonce. Every signer then has to build the
* same session from the same set in the same order -- FROST binds the set into
* the challenge, so a device that disagrees about who is signing produces a
* partial signature that will not aggregate.
*/
class FrostSignerIdsTag(
val signerIds: List<Int>,
) {
fun toTagArray() = assemble(signerIds = signerIds)
companion object {
const val TAG_NAME = "frost_signers"
fun parse(tag: Array<String>): FrostSignerIdsTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
// Order is meaningful, so a single unparseable entry invalidates the
// whole list rather than quietly shortening it.
val ids = tag[1].split(",").map { it.trim().toIntOrNull() ?: return null }
if (ids.isEmpty()) return null
return FrostSignerIdsTag(signerIds = ids)
}
fun assemble(signerIds: List<Int>): Array<String> =
arrayOf(TAG_NAME, signerIds.joinToString(","))
fun assemble(frostSignerIdsTag: FrostSignerIdsTag) =
assemble(signerIds = frostSignerIdsTag.signerIds)
}
}
@@ -0,0 +1,81 @@
package press.mantra.compose.nostr.nip30303
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
import press.mantra.compose.nostr.nip30303.tags.PayloadIdTag
import press.mantra.compose.nostr.nip30303.tags.PayloadKindTag
/**
* 30312
*
* An envelope that carries one nip30303 event into a group.
*
* Every other nip30303 kind describes a thing -- an artifact, a dialect, a
* chapter, a translated chunk. A submission describes an act: *this member is
* putting this event in front of this group*. The two are separate on purpose,
* because they answer different questions and often have different answers.
*
* The payload travels whole, in [content], keeping its own id, author and
* signature. Nothing is rewritten to make it look like the submitter's work.
* That buys two things:
*
* - A group can take in work written by somebody who is not in it. A
* translation lifted from a public archive, a chapter transcribed by an
* outside contributor, an artifact somebody published years ago -- an admin
* submits it and the group applies it, with the original author still named
* on the row.
* - Authorship stops being a claim the transport makes. Before this, being
* the sender of a group message *was* being the author of the event inside
* it, so the only events a group could hold were ones its own members had
* written under their own keys.
*
* A submission is not an endorsement and grants nothing: a payload's author is
* whoever signed it, and who may submit is the group's business.
*/
@Immutable
class SubmissionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
/**
* The submitted event, parsed out of [content].
*
* Null when the content is not an event at all -- treat that as a
* submission that cannot be applied, not as an empty one.
*/
fun payload(): Event? = Event.fromJsonOrNull(content)
fun payloadKind() = tags.firstNotNullOfOrNull(PayloadKindTag::parse)?.kind
fun payloadIdReference() = tags.firstNotNullOfOrNull(PayloadIdTag::parse)?.ref
fun payloadId() = payloadIdReference()?.eventId
/** Who wrote the payload, which is not who sent this submission. */
fun payloadAuthor() = payloadIdReference()?.author
companion object {
const val KIND = 30312
const val ALT_DESCRIPTION = "Submission"
fun build(
payload: Event,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<SubmissionEvent>.() -> Unit = {},
) = eventTemplate(KIND, payload.toJson(), createdAt) {
alt(ALT_DESCRIPTION)
addUnique(PayloadKindTag.assemble(payload.kind))
addUnique(PayloadIdTag.assemble(eventId = payload.id, pubkey = payload.pubKey))
initializer()
}
}
}
@@ -0,0 +1,47 @@
package press.mantra.compose.nostr.nip30303.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
/**
* The event a submission carries, named on the submission itself.
*
* The payload is already in the submission's content, so this tag is not how a
* reader gets at it -- it is how they filter for it. The author slot matters
* more than usual here: on a submission it names whoever wrote the payload,
* who is not necessarily the member who submitted it.
*/
data class PayloadIdTag(
val ref: EventReference,
) {
constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this(
EventReference(eventId, pubkey, relayHint)
)
companion object {
const val TAG_NAME = "payloadId"
fun parse(tag: Array<String>): PayloadIdTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
return PayloadIdTag(tag[1], relayHint, tag.getOrNull(3))
}
fun assemble(
eventId: HexKey,
relay: NormalizedRelayUrl? = null,
pubkey: String? = null,
) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey)
fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author)
}
}
@@ -0,0 +1,43 @@
package press.mantra.compose.nostr.nip30303.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The kind of the event a submission carries.
*
* Lets a client decide whether it can apply a submission without parsing the
* payload JSON out of the content first.
*/
class PayloadKindTag(
val kind: Int,
) {
fun toTagArray() = assemble(
kind = kind,
)
companion object {
const val TAG_NAME = "payloadKind"
fun parse(tag: Array<String>): PayloadKindTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val kind = tag[1].toIntOrNull() ?: return null
return PayloadKindTag(
kind = kind,
)
}
fun assemble(
kind: Int,
): Array<String> = arrayOf(
TAG_NAME,
kind.toString()
)
fun assemble(payloadKindTag: PayloadKindTag) = assemble(
kind = payloadKindTag.kind,
)
}
}
@@ -0,0 +1,94 @@
package press.mantra.compose.repository
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
/**
* Reads, opens and answers FROST signing sessions.
*
* Advancing one is the inbound path's job, with one exception: a session waiting
* on this device's owner does not advance until [approve] is called, because it
* will not sign on their behalf until they say so.
*/
interface FrostSigningRepository {
fun observeSessionById(sessionId: String): Flow<FrostSigningSession?>
/** Every session the room has run, newest first. Unlike a ceremony, signing recurs. */
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>>
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
suspend fun getSessionById(sessionId: String): FrostSigningSession?
/**
* The session a room is currently running, or its most recent one if none is.
*
* For callers that mean "the signing going on here" without holding an id --
* a transcript line, mostly. Prefers a live session because that is the one
* anybody tapping through wants to act on; a finished one is only what is
* left to show when there is nothing live.
*/
suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession?
/** Whether this room holds a key it can sign with, so the UI offers nothing that would fail. */
suspend fun canSign(chatRoomId: String): Boolean
/**
* Opens a session asking the group to sign an event with these fields. The
* author is the group's key, not the proposer's, and is filled in here.
*/
suspend fun proposeSigning(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
kind: Kind,
tags: Array<Array<String>>,
content: String
): FrostSigningSession?
/** Agrees to sign, letting the session publish this device's part and run on. */
suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String)
/** Refuses, and says so, since a t-of-n group can proceed without this member. */
suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String)
/** The finished event, or null while the session is still running. */
fun signedEvent(session: FrostSigningSession): Event?
companion object {
val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository {
override fun observeSessionById(sessionId: String): Flow<FrostSigningSession?> = flowOf(null)
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
flowOf(emptyList())
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
flowOf(emptyList())
override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null
override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null
override suspend fun canSign(chatRoomId: String): Boolean = false
override suspend fun proposeSigning(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
kind: Kind,
tags: Array<Array<String>>,
content: String
): FrostSigningSession? = null
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit
override fun signedEvent(session: FrostSigningSession): Event? = null
}
}
}
@@ -9,7 +9,6 @@ import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationChapter
import press.mantra.compose.database.model.MantraTranslationChunk
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
interface MantraRepository {
@@ -65,7 +64,7 @@ interface MantraRepository {
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
): MantraTranslationArtifactVersion?
/**
* Add a chapter (markdown [originalText]) to the artifact's latest version.
@@ -79,7 +78,7 @@ interface MantraRepository {
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
): MantraChapter?
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
@@ -91,7 +90,7 @@ interface MantraRepository {
country: String,
language: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
): MantraDialect?
suspend fun addArtifact(
localChatRoom: LocalChatRoom,
@@ -102,14 +101,14 @@ interface MantraRepository {
userPublicKey: HexKey,
visibility: String = DEFAULT_VISIBILITY,
license: String = DEFAULT_LICENSE,
): MarmotInnerEvent?
): MantraArtifact?
suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent?
): MantraArtifactVersion?
companion object {
const val DEFAULT_VISIBILITY = "private"
@@ -155,7 +154,7 @@ interface MantraRepository {
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
): MantraTranslationArtifactVersion? = null
override suspend fun addChapter(
artifactId: String,
@@ -163,7 +162,7 @@ interface MantraRepository {
originalText: String,
chatRoomId: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
): MantraChapter? = null
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
@@ -175,7 +174,7 @@ interface MantraRepository {
country: String,
language: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
): MantraDialect? = null
override suspend fun addArtifact(
localChatRoom: LocalChatRoom,
@@ -186,14 +185,14 @@ interface MantraRepository {
userPublicKey: HexKey,
visibility: String,
license: String,
): MarmotInnerEvent? = null
): MantraArtifact? = null
override suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
versionLabel: String,
userPublicKey: HexKey,
): MarmotInnerEvent? = null
): MantraArtifactVersion? = null
}
}
}
@@ -18,15 +18,14 @@ import androidx.compose.material.icons.filled.Label
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Title
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -36,6 +35,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -45,6 +45,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -108,14 +110,21 @@ fun AddArtifactScreen(
val nameFieldState = rememberTextFieldState()
val urlFieldState = rememberTextFieldState()
val versionLabelFieldState = rememberTextFieldState("1.0")
val dialectNameFieldState = rememberTextFieldState()
val dialectCountryFieldState = rememberTextFieldState()
val dialectLanguageFieldState = rememberTextFieldState()
// null = "New dialect" (show the create fields); otherwise the id of
// an existing dialect to reuse.
// The id of the group dialect this artifact is written in. Null until
// one is picked; dialects are defined from the group detail screen.
var selectedDialectId: String? by remember { mutableStateOf(null) }
// Of the required fields this is the only one the screen cannot ask
// for again: a dialect has to already exist, and nothing here can
// create one. So an unpicked dialect is a dead end rather than
// something to submit and be told about, and the button says so.
val canAddArtifact = selectedDialectId != null
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
topBar = {
TopAppBar(
@@ -132,16 +141,33 @@ fun AddArtifactScreen(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canAddArtifact) {
Modifier
} else {
// Looking unavailable is not being unavailable:
// without this a screen reader still announces
// a button it is happy to press.
Modifier.semantics { disabled() }
},
containerColor = if (canAddArtifact) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canAddArtifact) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!canAddArtifact) return@ExtendedFloatingActionButton
addArtifactViewModel.addArtifact(
localChatRoom = addArtifactUIState.localChatRoom,
nameField = nameFieldState,
urlField = urlFieldState,
versionLabelField = versionLabelFieldState,
existingDialectId = selectedDialectId,
dialectNameField = dialectNameFieldState,
dialectCountryField = dialectCountryFieldState,
dialectLanguageField = dialectLanguageFieldState,
dialectId = selectedDialectId,
onSuccess = { artifactId ->
// Open the newly created artifact, removing this
// add screen from the back stack.
@@ -266,112 +292,24 @@ fun AddArtifactScreen(
Text("Source dialect")
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
addArtifactUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
)
if (addArtifactUIState.dialects.isEmpty()) {
Text(
text = "No dialects have been defined in this group yet. Add one from the group's detail screen first.",
style = MaterialTheme.typography.bodySmall
)
} else {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
addArtifactUIState.dialects.forEach { dialect ->
FilterChip(
selected = selectedDialectId == dialect.id,
onClick = { selectedDialectId = dialect.id },
label = { Text(dialect.name) }
)
}
}
FilterChip(
selected = selectedDialectId == null,
onClick = { selectedDialectId = null },
leadingIcon = {
Icon(
Icons.Default.Add,
contentDescription = "Create a new dialect"
)
},
label = { Text("New dialect") }
)
}
// Only collect new-dialect details when not reusing an existing one.
if (selectedDialectId == null) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = dialectNameFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Title,
contentDescription = "Name of the dialect"
)
},
label = {
Text(
text = "Dialect Name"
)
},
placeholder = {
Text(
text = "eg. Sesotho"
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = dialectCountryFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Public,
contentDescription = "Country of the dialect"
)
},
label = {
Text(
text = "Country"
)
},
placeholder = {
Text(
text = "eg. Lesotho"
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = dialectLanguageFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Translate,
contentDescription = "Language of the dialect"
)
},
label = {
Text(
text = "Language"
)
},
placeholder = {
Text(
text = "eg. st"
)
},
)
}
// TODO: Add Visibility
@@ -0,0 +1,348 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Title
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.AddDialectViewModel
import press.mantra.compose.ui.view.state.AddDialectUIState
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AddDialectScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
onNavigateToRoute: (Route) -> Unit,
) {
val addDialectViewModel: AddDialectViewModel = viewModel(
factory = AddDialectViewModel.factory(
chatRoomId = chatRoomId,
relayHint = relayHint,
initialAddDialectUIState = initialAddDialectUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
activeUserPublicKey = activeUserPublicKey,
frostSigningRepository = frostSigningRepository
)
)
when (val addDialectUIState = addDialectViewModel.addDialectUIState) {
is AddDialectUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = addDialectUIState.message,
)
}
}
is AddDialectUIState.Loaded -> {
val nameFieldState = rememberTextFieldState()
val countryFieldState = rememberTextFieldState()
val languageFieldState = rememberTextFieldState()
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
topBar = {
TopAppBar(
title = {
addDialectUIState.localChatRoom.RenderChatRoomTitleText()
},
actions = {
}
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (addDialectUIState.canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (addDialectUIState.canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (addDialectUIState.canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton
addDialectViewModel.addDialect(
localChatRoom = addDialectUIState.localChatRoom,
nameField = nameFieldState,
countryField = countryFieldState,
languageField = languageFieldState,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the group. Nothing has been created
// yet -- the dialect appears when enough
// members sign -- so landing on the list
// it is not in would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed Dialect")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Propose dialect"
)
Text("Propose Dialect")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Add a dialect the group can translate into")
if (!addDialectUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"dialect into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = nameFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Title,
contentDescription = "Name of the dialect"
)
},
label = {
Text(
text = "Dialect Name"
)
},
placeholder = {
Text(
text = "eg. Sesotho"
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = countryFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Public,
contentDescription = "Country of the dialect"
)
},
label = {
Text(
text = "Country"
)
},
placeholder = {
Text(
text = "eg. Lesotho"
)
},
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
state = languageFieldState,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
leadingIcon = {
Icon(
Icons.Default.Translate,
contentDescription = "Language of the dialect"
)
},
label = {
Text(
text = "Language"
)
},
placeholder = {
Text(
text = "eg. st"
)
},
)
}
}
}
}
AddDialectUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
20.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "Add Dialect",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
LaunchedEffect(true) {
if (initialAddDialectUIState == AddDialectUIState.Loading) {
addDialectViewModel.initiateAddDialect()
}
}
}
@Preview
@Composable
private fun AddDialectScreenPreview() {
TorchTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
AddDialectScreen(
activeUserPublicKey = "",
chatRoomId = "publicKey",
relayHint = null,
initialAddDialectUIState = AddDialectUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = "",
userPublicKey = "",
subject = "Message title",
description = "See something. Say somethin",
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
),
)
),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateToRouteAndPopUpInclusive = {},
onNavigateToRoute = {}
)
}
}
}
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Schema
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Unsubscribe
import androidx.compose.material.icons.filled.WaterfallChart
import androidx.compose.material3.ButtonDefaults
@@ -60,6 +61,7 @@ import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel
import press.mantra.compose.ui.view.state.ChatRoomDetailUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@@ -217,6 +219,72 @@ fun ChatRoomDetailScreen(
HorizontalDivider()
}
item {
// Dialects
Text(
text = "Dialects",
style = MaterialTheme.typography.labelMedium
)
}
if (chatRoomDetailUIState.dialects.isEmpty()) {
item {
Text("No dialects have been defined in this group.")
}
} else {
items(
items = chatRoomDetailUIState.dialects,
key = { dialect -> dialect.id }
) { dialect ->
Card {
ListItem(
leadingContent = {
Icon(
Icons.Default.Translate,
contentDescription = "Dialect"
)
},
headlineContent = {
Text(text = dialect.name)
},
supportingContent = {
Text(text = "${dialect.language} \u00b7 ${dialect.country}")
}
)
}
}
}
item {
TextButton(
onClick = {
onNavigateToRoute.invoke(
AddDialectRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.Translate,
contentDescription = "Add new dialect"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text("Add Dialect")
}
}
item {
HorizontalDivider()
}
// TODO: Add projects...
item {
@@ -43,6 +43,7 @@ import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
@@ -141,6 +142,16 @@ fun ChatRoomMessagingScreen(
chatRoomId = chatRoomId
)
)
},
onOpenSigning = {
// No session id: a chat row carries none, and the
// screen resolves the room's live one.
onNavigateToRoute.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
)
}
)
}
@@ -0,0 +1,373 @@
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.FrostSigningViewModel
import press.mantra.compose.ui.view.state.FrostSigningUIState
/**
* One signing session, and the member's decision about it.
*
* A ceremony gets three approval screens because it asks three different
* questions. Signing asks one — sign this or do not — so there is one screen,
* and it has to carry the whole case for the answer: what is being signed, who
* else has agreed, and what the group is still waiting on.
*
* It stays useful after the decision. A session cannot finish until enough
* members take part, so a member who has already signed still needs to see
* whose door to knock on, and the ladder is the only place that says.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FrostSigningScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
sessionId: String?,
initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateBack: () -> Unit,
) {
val frostSigningViewModel: FrostSigningViewModel = viewModel(
factory = FrostSigningViewModel.factory(
chatRoomId = chatRoomId,
sessionId = sessionId,
activeUserPublicKey = activeUserPublicKey,
initialFrostSigningUIState = initialFrostSigningUIState,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
)
// Nothing loads the room or starts watching the session until this runs.
LaunchedEffect(true) {
if (initialFrostSigningUIState == FrostSigningUIState.Loading) {
frostSigningViewModel.initiate()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Sign with the group's key",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
when (val state = frostSigningViewModel.frostSigningUIState) {
is FrostSigningUIState.Loading -> Loading(padding)
is FrostSigningUIState.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 FrostSigningUIState.Loaded -> {
// Loaded means the room is loaded, not the session: initiate() sets
// this state and only then starts collecting, so the first emission
// always has a null session. Reading that as "no such session" would
// flash an error on the way in.
val session = state.session ?: return@Scaffold Loading(padding)
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(15.dp)
) {
WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session))
HorizontalDivider()
Text(
text = statusOf(session),
style = MaterialTheme.typography.bodyMedium
)
session.failureReason?.let { reason ->
Text(
text = reason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
HorizontalDivider()
Text(
text = "Members",
style = MaterialTheme.typography.labelMedium
)
// Named rather than counted, for the same reason the ceremony's
// ladder names people: "1 of 2" does not tell anyone whose door
// to knock on, and a session stalls until somebody knocks.
state.localChatRoom.localParticipants
.distinctBy { it.participant.participantPublicKey }
.forEach { localParticipant ->
val member = localParticipant.participant.participantPublicKey
Card {
ListItem(
leadingContent = {
ProfileAvatar(
publicKey = member,
profile = localParticipant.profile
)
},
trailingContent = {
when {
member in state.signed -> Icon(
Icons.Default.CheckCircle,
contentDescription = "Signed",
tint = MaterialTheme.colorScheme.primary
)
member in state.offeredNonce -> Icon(
Icons.Default.HourglassEmpty,
contentDescription = "Ready to sign"
)
else -> Icon(
Icons.Default.RadioButtonUnchecked,
contentDescription = "Not yet",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
headlineContent = {
Text(
text = localParticipant.profile
?.humanReadableNameOrPubkey()
?: member
)
},
supportingContent = {
Text(
text = when {
member in state.signed -> "Signed their part"
member in state.offeredNonce -> "Ready to sign"
else -> "Has not taken part yet"
}
)
}
)
}
}
if (session.signApprovedAt == null &&
session.stage != FrostSigningStage.COMPLETE &&
session.stage != FrostSigningStage.FAILED
) {
HorizontalDivider()
Text(
text = "Nothing has been published from this device yet. Signing " +
"puts your share behind this event; it cannot be taken back.",
style = MaterialTheme.typography.bodySmall
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
enabled = !frostSigningViewModel.isActionPending.value,
onClick = { frostSigningViewModel.approve(onNavigateBack) }
) {
Icon(Icons.Default.Draw, contentDescription = null)
Spacer(modifier = Modifier.width(10.dp))
Text("Sign")
}
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
enabled = !frostSigningViewModel.isActionPending.value,
onClick = { frostSigningViewModel.decline(onNavigateBack) }
) {
Text("Don't sign")
}
}
}
}
}
}
}
}
@Composable
private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) {
Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
CircularProgressIndicator()
}
}
/**
* What the group is being asked to put its name to.
*
* Shown as the thing rather than as an event: a member deciding whether to sign
* is deciding about a dialect or an artifact, and "kind 30304" answers a
* question nobody asked. The raw kind stays for anything not recognised, since
* refusing to describe an event is better than describing it wrongly.
*/
@Composable
private fun WhatIsBeingSigned(event: Event?) {
if (event == null) {
Text(
text = "This session's event could not be read, so there is nothing to check " +
"before signing. Don't sign it.",
color = MaterialTheme.colorScheme.error
)
return
}
val (label, detail) = when (event.kind) {
DialectEvent.KIND -> "New dialect" to DialectEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).let { dialect ->
listOfNotNull(dialect.name(), dialect.country(), dialect.language())
.joinToString(" · ")
}
ArtifactEvent.KIND -> "New artifact" to event.content
ChapterEvent.KIND -> "New chapter" to ChapterEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).name().orEmpty()
else -> "Event of kind ${event.kind}" to event.content
}
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = detail, style = MaterialTheme.typography.titleMedium)
Text(
text = "Signed by the group, not by you. Once enough members sign, this is " +
"published under the group's shared key.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
private fun statusOf(session: FrostSigningSession): String = when (session.stage) {
FrostSigningStage.COLLECTING_NONCES ->
"Waiting for ${session.threshold} of ${session.participantCount} members to take part."
FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES ->
if (session.isSigner()) {
"You are one of the signers. Waiting on the rest of them."
} else {
"Enough members took part without needing you. Waiting on them to sign."
}
FrostSigningStage.COMPLETE -> "Signed."
FrostSigningStage.FAILED -> "Abandoned. Nothing was signed, and it is safe to ask again."
}
@Preview
@Composable
private fun FrostSigningScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
FrostSigningScreen(
activeUserPublicKey = "",
chatRoomId = "chatRoomId",
sessionId = "sessionId",
initialFrostSigningUIState = FrostSigningUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = "chatRoomId",
userPublicKey = "",
subject = "Group (#admins)",
description = null,
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
)
)
),
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateBack = {}
)
}
}
}
@@ -16,6 +16,7 @@ import androidx.navigation.toRoute
import press.mantra.compose.MantraGlobal
import press.mantra.compose.database.repository.DatabaseChatRepository
import press.mantra.compose.database.repository.DatabaseDkgRepository
import press.mantra.compose.database.repository.DatabaseFrostSigningRepository
import press.mantra.compose.database.repository.DatabaseMarmotRepository
import press.mantra.compose.database.repository.DatabaseNostrRepository
import press.mantra.compose.database.repository.DatabaseSearchRepository
@@ -103,6 +104,8 @@ import kotlinx.coroutines.launch
import press.mantra.compose.database.repository.DatabaseMantraRepository
import press.mantra.compose.ui.composable.AddArtifactScreen
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
@@ -112,6 +115,8 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRo
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.ArtifactDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
import press.mantra.compose.ui.composable.AddDialectScreen
import press.mantra.compose.ui.composable.FrostSigningScreen
import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen
import press.mantra.compose.ui.composable.ChapterDetailScreen
import press.mantra.compose.ui.composable.TranslateChunkScreen
@@ -162,6 +167,13 @@ fun MantraNavHost(
)
}
val databaseFrostSigningRepository = remember {
DatabaseFrostSigningRepository(
database = auxDatabaseManager.auxDatabase,
applicationIOScope
)
}
val databaseMarmotRepository = remember {
DatabaseMarmotRepository(
database = auxDatabaseManager.auxDatabase,
@@ -814,6 +826,46 @@ fun MantraNavHost(
}
)
}
composable<AddDialectRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddDialectRoute>()
AddDialectScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
// Replace this add screen so back returns to the group rather
// than to a form whose proposal has already gone out.
navController.navigate(route = signingRoute) {
popUpTo<AddDialectRoute> {
inclusive = true
}
}
},
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
)
}
)
}
composable<FrostSigningRoute> { backStackEntry ->
val route = backStackEntry.toRoute<FrostSigningRoute>()
FrostSigningScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
sessionId = route.sessionId,
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<ArtifactDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ArtifactDetailRoute>()
@@ -0,0 +1,10 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class AddDialectRoute(
val activeUserPublicKey: String,
val chatRoomId: String, // TODO: have this as a publicKey
val relayHint: String?
): Route()
@@ -0,0 +1,27 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* One signing session.
*
* Carries a session id rather than only a room, because a group signs
* repeatedly and can have more than one session open at a time -- unlike a
* ceremony, where "the room's ritual" identifies it.
*/
@Serializable
data class FrostSigningRoute(
val activeUserPublicKey: String,
val chatRoomId: String,
/**
* Null when the caller does not know which session it means.
*
* A transcript line is the main case: chat rows carry no session, and adding
* a column for one feature to a table every message uses is a poor trade for
* a lookup the screen can do. It resolves to the room's live session, which
* is the one a line is talking about in every case but a group running two
* at once.
*/
val sessionId: String? = null
): Route()
@@ -62,10 +62,7 @@ class AddArtifactViewModel(
nameField: TextFieldState,
urlField: TextFieldState,
versionLabelField: TextFieldState,
existingDialectId: HexKey?,
dialectNameField: TextFieldState,
dialectCountryField: TextFieldState,
dialectLanguageField: TextFieldState,
dialectId: HexKey?,
visibility: String = MantraRepository.DEFAULT_VISIBILITY,
license: String = MantraRepository.DEFAULT_LICENSE,
onSuccess: (artifactId: String) -> Unit,
@@ -75,16 +72,10 @@ class AddArtifactViewModel(
val name = nameField.text.toString()
val url = urlField.text.toString()
val versionLabel = versionLabelField.text.toString()
val dialectName = dialectNameField.text.toString()
val dialectCountry = dialectCountryField.text.toString()
val dialectLanguage = dialectLanguageField.text.toString()
// When no existing dialect is selected, the new-dialect fields are required.
val creatingNewDialect = existingDialectId.isNullOrBlank()
val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank()
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() ||
(creatingNewDialect && newDialectIncomplete)
) {
// addArtifact requires an existing source dialect; they are defined
// from the group detail screen, not here.
if (name.isBlank() || url.isBlank() || versionLabel.isBlank() || dialectId.isNullOrBlank()) {
onFailure.invoke()
return
}
@@ -94,21 +85,7 @@ class AddArtifactViewModel(
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val artifactInnerEvent = runCatching {
// Reuse the selected dialect, or create a new source dialect and
// reference it by id. addArtifact requires a valid dialectId.
val dialectId = if (creatingNewDialect) {
mantraRepository.addDialect(
localChatRoom = localChatRoom,
name = dialectName,
country = dialectCountry,
language = dialectLanguage,
userPublicKey = activeUserPublicKey,
)?.id ?: return@runCatching null
} else {
existingDialectId
}
val artifact = runCatching {
mantraRepository.addArtifact(
localChatRoom = localChatRoom,
name = name,
@@ -123,16 +100,13 @@ class AddArtifactViewModel(
logger.e("Failed to add artifact", error)
}.getOrNull()
if (artifactInnerEvent != null) {
if (artifact != null) {
nameField.clearText()
urlField.clearText()
versionLabelField.clearText()
dialectNameField.clearText()
dialectCountryField.clearText()
dialectLanguageField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(artifactInnerEvent.id)
onSuccess.invoke(artifact.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
@@ -0,0 +1,162 @@
package press.mantra.compose.ui.view.model
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.ui.view.state.AddDialectUIState
class AddDialectViewModel(
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialAddDialectUIState: AddDialectUIState,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var addDialectUIState: AddDialectUIState by mutableStateOf(initialAddDialectUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/**
* Whether this group holds a key it can sign with.
*
* Read before the form is offered: a group without one cannot make a dialect
* this way at all, and a button that always fails is worse than no button.
*/
suspend fun canSign(): Boolean = frostSigningRepository.canSign(chatRoomId)
fun initiateAddDialect() {
logger.d("compressed (most likely chat room): $chatRoomId")
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
addDialectUIState = if (localChatRoom == null) {
AddDialectUIState.Error("Couldn't find the chat room")
} else {
AddDialectUIState.Loaded(
localChatRoom = localChatRoom,
canSign = frostSigningRepository.canSign(chatRoomId),
)
}
}
}
/**
* Asks the group to sign a new dialect into existence.
*
* The dialect is not created here and does not exist yet. What goes out is a
* proposal to sign it, and the dialect appears -- on every member's device at
* once, authored by the group's shared key rather than by whoever typed it --
* when enough members have signed.
*
* That is the difference from submitting one. A submission says "I am putting
* this in front of the group" and the group's only recourse afterwards is
* social. A signature is the group saying it, and it takes a quorum to say.
*/
fun addDialect(
localChatRoom: LocalChatRoom,
nameField: TextFieldState,
countryField: TextFieldState,
languageField: TextFieldState,
onSuccess: (sessionId: String) -> Unit,
onFailure: () -> Unit
) {
val name = nameField.text.toString()
val country = countryField.text.toString()
val language = languageField.text.toString()
if (name.isBlank() || country.isBlank() || language.isBlank()) {
onFailure.invoke()
return
}
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val dialectEventTemplate = DialectEvent.build(
name = name,
country = country,
language = language,
)
val session = runCatching {
frostSigningRepository.proposeSigning(
localChatRoom = localChatRoom,
userPublicKey = activeUserPublicKey,
kind = dialectEventTemplate.kind,
tags = dialectEventTemplate.tags,
content = dialectEventTemplate.content,
)
}.onFailure { error ->
logger.e("Failed to propose a dialect for signing", error)
}.getOrNull()
if (session != null) {
nameField.clearText()
countryField.clearText()
languageField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(session.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
}
isActionPending.value = false
}
}
companion object {
private const val TAG = "AddDialectViewModel"
fun factory(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AddDialectViewModel(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialAddDialectUIState = initialAddDialectUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
}
}
}
}
@@ -24,6 +24,8 @@ 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.Draw
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PanTool
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Upload
@@ -215,7 +217,7 @@ class ChatMessageListViewModel(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RenderMessages(onOpenSharedKey: () -> Unit) {
fun RenderMessages(onOpenSharedKey: () -> Unit, onOpenSigning: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
@@ -270,8 +272,10 @@ class ChatMessageListViewModel(
val answeredRequests = chatRoomDetailMessageListUIState
.chatMessageList
.mapNotNull { request ->
val published = ChatMessage
.DKG_REQUEST_FULFILMENTS[request.chatMessage.messageType]
val published = (
ChatMessage.DKG_REQUEST_FULFILMENTS +
ChatMessage.FROST_REQUEST_FULFILMENTS
)[request.chatMessage.messageType]
?: return@mapNotNull null
val done = chatRoomDetailMessageListUIState.chatMessageList.any {
@@ -347,6 +351,20 @@ class ChatMessageListViewModel(
return@items
}
// Signing lines are the same kind of thing and get
// the same treatment -- nobody said them either --
// but they lead somewhere else, because what a
// reader needs from one is the event being signed
// rather than the state of the key.
if (localChatMessage.chatMessage.messageType in ChatMessage.FROST_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
onClick = onOpenSigning
)
return@items
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
@@ -537,6 +555,15 @@ private fun RitualNotice(
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck
ChatMessage.TYPE_FROST_STARTED -> Icons.Default.Draw
ChatMessage.TYPE_FROST_NONCE -> Icons.Default.Upload
ChatMessage.TYPE_FROST_SIGNER_SET -> Icons.Default.Groups
ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE -> Icons.Default.Draw
ChatMessage.TYPE_FROST_SIGNATURE -> Icons.Default.WorkspacePremium
ChatMessage.TYPE_FROST_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline
ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw
else -> Icons.Default.PanTool
}
@@ -545,10 +572,15 @@ private fun RitualNotice(
// 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 isRequest = (
chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES ||
chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES
) && !isAnswered
val tint = when {
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ->
MaterialTheme.colorScheme.error
isRequest -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
@@ -578,7 +610,9 @@ private fun RitualNotice(
// from the joined profile rather than written into the content,
// so it follows a rename and is not stuck on the "LOADING..."
// placeholder a member is given the moment they are first seen.
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES) {
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
chatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
) {
withStyle(
SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey))
) {
@@ -55,6 +55,7 @@ class ChatRoomDetailViewModel(
ChatRoomDetailUIState.Loaded(
localChatRoom = localChatRoom,
artifacts = mantraRepository.getArtifacts(chatRoomId),
dialects = mantraRepository.getDialects(chatRoomId),
)
}
}
@@ -0,0 +1,156 @@
package press.mantra.compose.ui.view.model
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.ui.view.state.FrostSigningUIState
class FrostSigningViewModel(
val chatRoomId: String,
val sessionId: String?,
val activeUserPublicKey: HexKey,
initialFrostSigningUIState: FrostSigningUIState,
val chatRepository: ChatRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var frostSigningUIState: FrostSigningUIState by mutableStateOf(initialFrostSigningUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/**
* Loads the room, then watches the session for as long as the screen lives.
*
* A session moves on messages arriving from other members, so a screen that
* read it once would sit still while the rest of the group signed around it.
*/
fun initiate() {
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
if (localChatRoom == null) {
frostSigningUIState = FrostSigningUIState.Error("Couldn't find the group")
return@launch
}
// A transcript line knows its room but not its session, so resolve one
// before watching anything.
val id = sessionId
?: frostSigningRepository.liveSessionForChatRoom(chatRoomId)?.id
if (id == null) {
frostSigningUIState =
FrostSigningUIState.Error("This group is not signing anything right now.")
return@launch
}
frostSigningUIState = FrostSigningUIState.Loaded(localChatRoom = localChatRoom)
combine(
frostSigningRepository.observeSessionById(id),
frostSigningRepository.observeMessages(id)
) { session, messages -> session to messages }
.collect { (session, messages) ->
frostSigningUIState = FrostSigningUIState.Loaded(
localChatRoom = localChatRoom,
session = session,
offeredNonce = messages
.filter { it.kind == FrostSigningEvents.NONCE }
.map { it.signerPublicKey }
.toSet(),
signed = messages
.filter { it.kind == FrostSigningEvents.PARTIAL_SIGNATURE }
.map { it.signerPublicKey }
.toSet()
)
}
}
}
/** The event the group is being asked to sign, for showing it before they agree. */
fun proposedEvent(session: FrostSigningSession): Event? =
Event.fromJsonOrNull(session.unsignedEventJson)
fun signedEvent(session: FrostSigningSession): Event? =
frostSigningRepository.signedEvent(session)
fun approve(onDone: () -> Unit) {
val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return
val sessionId = state.session?.id ?: return
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
frostSigningRepository.approve(
localChatRoom = state.localChatRoom,
sessionId = sessionId
)
isActionPending.value = false
viewModelScope.launch(Dispatchers.Main) { onDone.invoke() }
}
}
fun decline(onDone: () -> Unit) {
val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return
val sessionId = state.session?.id ?: return
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
frostSigningRepository.decline(
localChatRoom = state.localChatRoom,
sessionId = sessionId
)
isActionPending.value = false
viewModelScope.launch(Dispatchers.Main) { onDone.invoke() }
}
}
companion object {
private const val TAG = "FrostSigningViewModel"
fun factory(
chatRoomId: String,
sessionId: String?,
activeUserPublicKey: HexKey,
initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
FrostSigningViewModel(
chatRoomId = chatRoomId,
sessionId = sessionId,
activeUserPublicKey = activeUserPublicKey,
initialFrostSigningUIState = initialFrostSigningUIState,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
}
}
}
}
@@ -0,0 +1,22 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface AddDialectUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
/**
* Whether the group holds a shared key. A dialect is signed into
* existence now rather than submitted, so a group without one cannot
* make one here at all.
*/
val canSign: Boolean = false,
): AddDialectUIState
data class Error(
val message: String
): AddDialectUIState
data object Loading: AddDialectUIState
}
@@ -1,12 +1,14 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface ChatRoomDetailUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
val artifacts: List<MantraArtifact> = emptyList(),
val dialects: List<MantraDialect> = emptyList(),
): ChatRoomDetailUIState
data class Error(
@@ -0,0 +1,26 @@
package press.mantra.compose.ui.view.state
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface FrostSigningUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
/** Null on the first emission, before the session has been collected. */
val session: FrostSigningSession? = null,
/** Who has offered a nonce, so the screen can name who it is waiting on. */
val offeredNonce: Set<HexKey> = emptySet(),
/** Who has signed their part. */
val signed: Set<HexKey> = emptySet(),
): FrostSigningUIState
data class Error(
val message: String
): FrostSigningUIState
data object Loading: FrostSigningUIState
}
@@ -0,0 +1,174 @@
package press.mantra.compose.database.model
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.test.Test
import kotlin.test.assertEquals
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.ChunkEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
/**
* The row on disk and the payload on the wire have to be the same event.
*
* `MantraDao` writes an entity whose id comes from `MantraX.fromXEventTemplate`,
* and separately builds the rumor it submits with `rumorOf`, which hashes the
* template itself. Both are supposed to produce one id. Nothing checks that they
* do, and nothing would notice if they stopped:
*
* - the submission would carry a `payloadId` naming an event nobody has,
* - `MarmotInnerEvent.payloadEventId` would stop matching the row it carries,
* so `deleteByPayloadEventId` would silently un-queue nothing and superseded
* translations would go out anyway,
* - and every receiver would create a *second* row rather than converging on
* the sender's, because entity ids are content hashes and the two sides would
* be hashing different things.
*
* All of that is silent. The ids are opaque hex either way.
*/
class RumorIdAgreementTest {
private val author = "a".repeat(64)
private val chatRoomId = "room"
private val other = "b".repeat(64)
/** Exactly what `MantraDao.rumorOf` does, and it must stay exactly that. */
private fun rumorIdOf(template: EventTemplate<*>): String = EventHasher.hashId(
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content
)
@Test
fun `a dialect's row and its rumor agree`() {
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact's row and its rumor agree`() {
val template = ArtifactEvent.build(
name = "In Detention",
url = "example.com",
visibility = "private",
license = "cc",
dialectId = other
)
val entity = MantraArtifact.fromArtifactEventTemplate(
artifactEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `an artifact version's row and its rumor agree`() {
val template = ArtifactVersionEvent.build(content = "1.0") {
addUnique(ArtifactIdTag.assemble(other))
}
val entity = MantraArtifactVersion.fromArtifactVersionEventTemplate(
artifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `a chapter's and a chunk's rows agree with their rumors`() {
val chapter = ChapterEvent.build(
artifactVersionId = other,
name = "Chapter 1",
originalText = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
val chunk = ChunkEvent.build(
chapterId = other,
text = "some text",
index = 0,
wordCount = 2,
characterCount = 9
)
assertEquals(
rumorIdOf(chapter),
MantraChapter.fromChapterEventTemplate(chapter, chatRoomId, author)?.id
)
assertEquals(
rumorIdOf(chunk),
MantraChunk.fromChunkEventTemplate(chunk, chatRoomId, author)?.id
)
}
@Test
fun `a translation version's row and its rumor agree`() {
val template = TranslationArtifactVersionEvent.build(
artifactVersionId = other,
dialectId = other,
name = "Sesotho",
visibility = "private",
license = "cc"
)
val entity = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
translationArtifactVersionEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = author
)
assertEquals(rumorIdOf(template), entity?.id)
}
@Test
fun `the submission names the id the row was written under`() {
// The end of the chain the rest of this file checks a link of: what a
// receiver reads out of the envelope has to be the id the sender stored.
val template = DialectEvent.build(name = "Sesotho", country = "Lesotho", language = "st")
val entity = MantraDialect.fromDialectEventTemplate(template, chatRoomId, author)
val payload = Event(
id = rumorIdOf(template),
pubKey = author,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = ""
)
val submission = SubmissionEvent.build(payload = payload)
val readBack = SubmissionEvent(
id = "f".repeat(64),
pubKey = author,
createdAt = submission.createdAt,
tags = submission.tags,
content = submission.content,
sig = ""
)
assertEquals(entity?.id, readBack.payloadId())
assertEquals(entity?.id, readBack.payload()?.id)
assertEquals(DialectEvent.KIND, readBack.payloadKind())
}
}
@@ -0,0 +1,278 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.crypto.frost.Frost
import fr.acinq.bitcoin.crypto.frost.IndividualNonce
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
import fr.acinq.bitcoin.crypto.frost.SecretNonce
import fr.acinq.bitcoin.crypto.frost.Session
import fr.acinq.bitcoin.crypto.frost.TweakCache
import fr.acinq.secp256k1.Hex
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.extensions.toHex
import press.mantra.compose.nostr.frost.FrostSigningEvents
/**
* The two rounds a signing session runs, against real FROST.
*
* `FrostSigningManager` spreads these steps across arriving messages, several
* devices and a database, none of which a unit test can stand up. What it can
* do is run the same calls in the same order with the same arguments and check
* that what comes out is a signature nostr will accept — which is the part
* that was written from reading the library rather than from a working example,
* and so the part most likely to be subtly wrong.
*
* A signature that verifies is the whole contract: if these calls are wired up
* incorrectly the aggregate simply fails to verify, silently, on every device.
*/
class FrostSigningRoundTest {
private val participants = 3
private val threshold = 2
/** Stands in for a completed ceremony. A trusted dealer is fine here: the test is about signing. */
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
thresholdSecretKey = PrivateKey(
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
),
nParticipants = participants,
threshold = threshold
)
private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey)
/** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */
private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex()
/** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */
private fun eventId(content: String): String = EventHasher.hashId(
pubKey = groupPubKey,
createdAt = 1_700_000_000L,
kind = 1,
tags = arrayOf(),
content = content
)
/**
* One signer's half of the protocol, in the manager's order: regenerate the
* nonce from stored randomness, then sign once the set is known.
*/
private fun nonceOf(signerId: Int, message: ByteVector, random: String): Pair<SecretNonce, IndividualNonce> =
SecretNonce.generate(
sessionRandom = ByteVector32(random),
secretShare = keyMaterial.secretShares[signerId],
publicShare = keyMaterial.publicShares[signerId],
tweakedThresholdPublicKey = tweakCache.tweakedPublicKey,
message = message,
extraInput = null
)
private fun sessionFor(signerIds: List<Int>, nonces: List<IndividualNonce>, message: ByteVector): Session {
val aggregated = IndividualNonce.aggregate(nonces).right!!
return Session.create(
aggregatedNonce = aggregated,
signerIds = signerIds.map { it.toUInt() },
signerPublicShares = signerIds.map { keyMaterial.publicShares[it] },
nParticipants = participants,
threshold = threshold,
tweakCache = tweakCache,
message = message
)
}
@Test
fun `a threshold of signers produces a signature nostr accepts`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
// Two of the three sign, which is the point of a 2-of-3 key.
val signerIds = listOf(0, 1)
val nonces = signerIds.map { nonceOf(it, message, "a".repeat(63) + "${it + 1}") }
val session = sessionFor(signerIds, nonces.map { it.second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
val signature = session.aggregateSigs(partials).right!!
assertTrue(
Nip01Crypto.verify(
signature = signature.toByteArray(),
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"the aggregated signature must verify against the group's x-only key"
)
}
@Test
fun `a different pair of signers signs the same event just as well`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
// Whoever happens to be available. The coordinator picks; the signature
// that comes out must not depend on which t it picked.
val signerIds = listOf(1, 2)
val nonces = signerIds.map { nonceOf(it, message, "b".repeat(63) + "${it + 1}") }
val session = sessionFor(signerIds, nonces.map { it.second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
val signature = session.aggregateSigs(partials).right!!
assertTrue(
Nip01Crypto.verify(
signature = signature.toByteArray(),
hash = id.hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
)
)
}
@Test
fun `a signature over one event does not verify against another`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
val signerIds = listOf(0, 1)
val nonces = signerIds.map { nonceOf(it, message, "c".repeat(63) + "${it + 1}") }
val session = sessionFor(signerIds, nonces.map { it.second }, message)
val partials = signerIds.mapIndexed { position, signerId ->
session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!!
}
val signature = session.aggregateSigs(partials).right!!
assertFalse(
Nip01Crypto.verify(
signature = signature.toByteArray(),
hash = eventId("the group agrees to something else").hexToByteArray(),
pubKey = groupPubKey.hexToByteArray()
),
"a signature is over one event id and must not carry to another"
)
}
@Test
fun `regenerating a nonce from the same seed and message gives the same nonce`() {
val id = eventId("the group agrees")
val message = ByteVector(id.hexToByteArray())
val random = "d".repeat(63) + "1"
// What makes a signing session restart-safe: SecretNonce cannot be stored,
// so the manager keeps its seed and derives again. If that were not
// reproducible a device that restarted mid-session would publish a partial
// signature against a nonce nobody aggregated.
val first = nonceOf(0, message, random).second
val second = nonceOf(0, message, random).second
assertEquals(first.data.toHex(), second.data.toHex())
}
@Test
fun `the same seed under a different message gives a different nonce`() {
val random = "e".repeat(63) + "1"
// The safety property behind reusing the seed at all: one session signs one
// message. Were the nonce independent of the message, a session that could
// be re-pointed at another event would sign twice under one nonce, which
// hands over the secret share.
val first = nonceOf(0, ByteVector(eventId("one thing").hexToByteArray()), random).second
val second = nonceOf(0, ByteVector(eventId("another thing").hexToByteArray()), random).second
assertFalse(first.data.toHex() == second.data.toHex())
}
}
/**
* The pure bits of a signing session's bookkeeping: who is signing, and with
* which key.
*/
class FrostSigningSessionTest {
private fun session(signerId: Int, signerIds: String?) = FrostSigningSession(
id = "s".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
userPublicKey = "u".repeat(64),
dkgSessionId = "k".repeat(64),
threshold = 2,
participantCount = 3,
signerId = signerId,
unsignedEventJson = "{}",
eventId = "e".repeat(64),
nonceRandom = "f".repeat(64),
signerIds = signerIds
)
@Test
fun `a member left out of the signer set is not a signer`() {
assertTrue(session(signerId = 1, signerIds = "0,1").isSigner())
assertFalse(session(signerId = 2, signerIds = "0,1").isSigner())
}
@Test
fun `nobody is a signer until the coordinator has chosen`() {
assertFalse(session(signerId = 0, signerIds = null).isSigner())
}
@Test
fun `the signer set keeps the order it was aggregated in`() {
// FROST binds the set into the challenge, so this list is not a set of ids
// but a sequence positionally matched to the aggregated nonce.
assertEquals(listOf(2, 0, 1), session(signerId = 0, signerIds = "2,0,1").signerIdList())
}
@Test
fun `a signer set tag survives the trip through a tag array`() {
val tags = FrostSigningEvents.assembleTags(
sessionId = "session",
dkgSessionId = "ceremony",
signerIds = listOf(2, 0, 1)
)
assertEquals("session", FrostSigningEvents.parseSessionId(tags))
assertEquals("ceremony", FrostSigningEvents.parseKey(tags))
assertEquals(listOf(2, 0, 1), FrostSigningEvents.parseSignerIds(tags))
}
@Test
fun `a ceremony that recorded no public shares reads back null rather than empty`() {
// Ceremonies completed before the column existed. Signing falls back to not
// cross-checking shares, which the FROST API allows, rather than refusing.
val ceremony = DkgSession(
id = "k".repeat(64),
chatRoomId = "room",
coordinatorPublicKey = "c".repeat(64),
userPublicKey = "u".repeat(64),
threshold = 2,
participantCount = 3,
hostPublicKey = "h".repeat(66),
round1Random = "1".repeat(64),
round2AuxRandom = "2".repeat(64)
)
assertEquals(null, ceremony.publicShareList())
assertEquals(
2,
ceremony.copy(
publicShares = listOf(
Hex.encode(ByteArray(33) { 2 }),
Hex.encode(ByteArray(33) { 3 })
).joinToString(",")
).publicShareList()?.size
)
}
}
@@ -0,0 +1,201 @@
package press.mantra.compose.managers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlinx.coroutines.runBlocking
/**
* The decision behind keeping an MLS group alive between messages.
*
* This cache exists because quartz drops a secret tree's skipped-generation keys
* on save, so rebuilding a group between two messages loses any message that
* arrives late -- permanently, and silently. See `docs/mls-skipped-keys.md`.
*
* Every one of these failures is invisible at runtime. Reuse too eagerly and a
* group carries on from a ratchet another writer has already moved, which
* corrupts decryption rather than failing it. Reuse too rarely and the cache
* does nothing at all, and the bug it was written for comes straight back with
* no symptom to notice. So the rule is asserted rather than reasoned about.
*/
class LiveInstanceCacheTest {
/** Stands in for an MlsGroup: mutable, and its persisted form is its content. */
private class Group(var state: String) {
/** How many times this particular instance was handed to a caller. */
var uses: Int = 0
}
private fun cache() = LiveInstanceCache<Group> { it.state }
@Test
fun `reuses the instance while nothing else has written`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it.uses++; it }
)
// The same object, not merely an equal one: what has to survive is the
// in-memory skipped-key map, which no amount of rebuilding recovers.
assertSame(first, second)
assertEquals(1, built)
assertEquals(2, second?.uses)
}
@Test
fun `rebuilds when something else wrote the stored state`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
val first = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Sending a message advances the sender ratchet and saves; adding a
// member does too. Carrying on from an instance that has been overtaken
// would diverge the ratchet, which is worse than not caching at all.
stored = "written by someone else"
val second = cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("written by someone else") },
save = { stored = it },
block = { it }
)
assertEquals(2, built)
assertEquals(false, first === second)
}
@Test
fun `persists whatever the block left behind`() = runBlocking {
val cache = cache()
var stored: String? = "start"
cache.withInstance(
key = "room",
storedState = stored,
build = { Group("start") },
save = { stored = it },
block = { it.state = "advanced" }
)
assertEquals("advanced", stored)
}
@Test
fun `the state it records is the one it compares against next time`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
repeat(3) {
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
// Every use moves the instance on, as decrypting a message does.
block = { group -> group.state = "advanced ${group.uses++}" }
)
}
// Recording the pre-block state instead would make every call look like
// somebody else had written, quietly turning the cache off.
assertEquals(1, built)
}
@Test
fun `does not run the block, or cache anything, when there is nothing to build`() = runBlocking {
val cache = cache()
var ran = false
val result = cache.withInstance<Unit>(
key = "room",
storedState = null,
build = { null },
save = { },
block = { ran = true }
)
assertNull(result)
assertEquals(false, ran)
assertEquals(0, cache.size())
}
@Test
fun `an instance whose use threw is not handed to the next caller`() = runBlocking {
val cache = cache()
var stored: String? = "start"
var built = 0
assertFailsWith<IllegalStateException> {
cache.withInstance<Unit>(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { error("decryption blew up half way") }
)
}
cache.withInstance(
key = "room",
storedState = stored,
build = { built++; Group("start") },
save = { stored = it },
block = { it }
)
// Half-advanced and never persisted: the next caller has to start from
// what is actually on disk, not from whatever the failure left in memory.
assertEquals(2, built)
}
@Test
fun `rooms are cached independently`() = runBlocking {
val cache = cache()
var storedA: String? = "a"
var storedB: String? = "b"
val a = cache.withInstance(
key = "roomA",
storedState = storedA,
build = { Group("a") },
save = { storedA = it },
block = { it }
)
val b = cache.withInstance(
key = "roomB",
storedState = storedB,
build = { Group("b") },
save = { storedB = it },
block = { it }
)
assertEquals(2, cache.size())
assertEquals(false, a === b)
}
}
@@ -0,0 +1,126 @@
package press.mantra.compose.nostr.nip30303
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
/**
* What a submission has to survive: the trip through a group.
*
* The envelope is only worth having if the event inside it comes out the other
* side unchanged -- same id, same author, same signature. The moment any of
* those is rewritten in transit, a group can no longer hold work by anyone but
* its own members, which is the whole reason submissions exist.
*/
class SubmissionEventTest {
private val submitter = "a".repeat(64)
private val outsider = "b".repeat(64)
/** A dialect written by somebody who is not in the group. */
private fun outsiderDialect(): Event {
val template = DialectEvent.build(
name = "Sesotho",
country = "Lesotho",
language = "st",
createdAt = 1_700_000_000L,
)
return Event(
id = EventHasher.hashId(
pubKey = outsider,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
),
pubKey = outsider,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = "c".repeat(128),
)
}
/** Send a submission template and read it back the way inbound does. */
private fun roundTrip(payload: Event): SubmissionEvent {
val template = SubmissionEvent.build(payload = payload, createdAt = 1_700_000_100L)
val onTheWire = Event.fromJson(
Event(
id = EventHasher.hashId(
pubKey = submitter,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
),
pubKey = submitter,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = "",
).toJson()
)
return SubmissionEvent(
id = onTheWire.id,
pubKey = onTheWire.pubKey,
createdAt = onTheWire.createdAt,
tags = onTheWire.tags,
content = onTheWire.content,
sig = onTheWire.sig,
)
}
@Test
fun `the payload comes back as the event that went in`() {
val dialect = outsiderDialect()
val payload = roundTrip(dialect).payload()
assertEquals(dialect.id, payload?.id)
assertEquals(dialect.pubKey, payload?.pubKey)
assertEquals(dialect.kind, payload?.kind)
assertEquals(dialect.content, payload?.content)
assertEquals(dialect.sig, payload?.sig)
}
@Test
fun `submitting does not make the submitter the author`() {
val dialect = outsiderDialect()
val submission = roundTrip(dialect)
assertEquals(submitter, submission.pubKey)
assertEquals(outsider, submission.payload()?.pubKey)
assertNotEquals(submission.pubKey, submission.payload()?.pubKey)
}
@Test
fun `the envelope names what it carries without being opened`() {
val dialect = outsiderDialect()
val submission = roundTrip(dialect)
assertEquals(DialectEvent.KIND, submission.payloadKind())
assertEquals(dialect.id, submission.payloadId())
assertEquals(outsider, submission.payloadAuthor())
}
@Test
fun `a payload the group cannot read is null rather than empty`() {
val submission = SubmissionEvent(
id = "d".repeat(64),
pubKey = submitter,
createdAt = 1_700_000_100L,
tags = arrayOf(),
content = "not an event",
sig = "",
)
assertNull(submission.payload())
}
}
+3
View File
@@ -9,5 +9,8 @@ silent, or a decision that looked arbitrary and was not.
| [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering |
| [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
| [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
| [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app |
Start with the ceremony if you are new to this area; the other two both assume it.
Read the skipped-keys note before debugging any "the other device never got it"
report — it is silent, and it looks like every other kind of delivery failure.
+194
View File
@@ -0,0 +1,194 @@
# Messages are lost when two arrive out of order
A group event that a relay hands back a moment late is dropped and cannot be
recovered. Two messages published in the same second reliably lose one of them.
This is a conformance gap in quartz's MLS implementation, not in this app. What
this app can do about it from outside the library is partial, and is described
at the end.
## Symptom
The receiver stores the kind:445 group event and produces nothing from it. No
inner event, no chat line, no error the user sees. `MarmotGroupEvent` is written
*before* the message is decrypted, so the row survives while everything
downstream of it silently does not:
```
receiver, room 6d8ec3ad ("Frosty (#admins)")
20:36:55 kind 9 chat message decrypted, applied
20:37:34 kind 30321 nonce decrypted, applied
20:37:34 kind 30320 proposal group event stored, no inner event
```
Both 20:37:34 events were published by the same sender in the same
`proposeSigning` call. Every message that arrived on its own decrypted fine; the
back-to-back pair lost exactly one.
Downstream the failure reads as something else entirely. In the case above a
FROST signing session never started on the receiver, because the proposal that
opens one never arrived — leaving a nonce filed against a session that will
never exist. An earlier instance of the same bug dropped a dialect, and the
artifact referencing it then failed a foreign key and rolled back its whole
transaction.
## Cause
MLS is specified to tolerate out-of-order delivery inside an epoch. RFC 9420
§9.1: a receiver that gets generation `N+1` before `N` derives the intermediate
keys and keeps them, so the older message can still be read when it turns up.
Quartz does implement this. `SecretTree` caches them:
```kotlin
// SecretTree.kt
private val skippedKeys = mutableMapOf<Pair<Int, Int>, KeyNonceGeneration>()
fun applicationKeyNonceForGeneration(leafIndex: Int, generation: Int): KeyNonceGeneration {
val cachedKey = skippedKeys.remove(Pair(leafIndex, generation))
if (cachedKey != null) { /* ...replay check... */ return cachedKey }
val state = getOrInitSender(leafIndex)
require(generation >= state.applicationGeneration) {
"Generation $generation already consumed (current: ${state.applicationGeneration})"
}
...
}
```
The gap is that the cache is never persisted:
```kotlin
// SecretTree.kt
fun exportSenderStates(): Map<Int, SenderRatchetState> = senderState.toMap()
fun importSenderStates(states: Map<Int, SenderRatchetState>) {
senderState.putAll(states)
}
```
`exportSenderStates()` returns the ratchet *positions* only. `MlsGroup.saveState()`
calls it (`senderRatchetStates = secretTree.exportSenderStates()`) and
`MlsGroup.restore()` calls `importSenderStates`. So `skippedKeys` exists only in
one `SecretTree` instance's memory.
That would be harmless if the group instance outlived the messages. It does not:
`NostrDao` rebuilds it from stored state for every inbound event and saves it
back afterwards. So the sequence is
1. generation 1 arrives, ratchet advances 0 → 2, generation 0's key goes into
`skippedKeys`
2. `saveState()` — `skippedKeys` is dropped on the floor
3. generation 0 arrives, a fresh tree is restored with
`applicationGeneration = 2`, the cache is empty, `require` fails
4. the exception is swallowed, the event yields no `ApplicationMessage`
Step 3 is terminal. The key is derived from a ratchet that has moved past it and
cannot be recovered, and nothing asks the sender to resend.
Verified against the published artifact rather than a checkout:
`quartz-1.14.0-sources.jar`, `commonMain/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt`.
## Why it is not an edge case here
Nostr relays make no ordering guarantee at all, and negentropy reconciliation
hands back a room's backlog in whatever order it likes. Any two messages close
enough together can swap.
Several flows publish in bursts, and each of them is a reliable trigger:
| flow | messages in one pass |
|---|---|
| `FrostSigningManager.proposeSigning` | proposal, then the proposer's nonce |
| `ChillDkgRitualManager.proposeRitual` | proposal, then the host key |
| `MantraDao.addArtifact` | the artifact, then its first version |
| `MantraDao.addChapter` | the chapter, then one per paragraph chunk |
`addChapter` is the worst of these: a chapter with twenty paragraphs publishes
twenty-one events at once, and only the ones that happen to arrive in ascending
generation order survive.
## The fix, in quartz
Carry the skipped keys through `saveState`/`restore` alongside the ratchet
positions.
**1. Export and import them.** In `SecretTree`:
```kotlin
fun exportSkippedKeys(): Map<Pair<Int, Int>, KeyNonceGeneration> = skippedKeys.toMap()
fun importSkippedKeys(keys: Map<Pair<Int, Int>, KeyNonceGeneration>) {
skippedKeys.putAll(keys)
}
```
`MAX_SKIPPED_KEYS` already bounds the map, so the serialised size is bounded by
the same constant and needs no separate cap.
**2. Put them in the group state.** `MlsGroup.saveState()` already writes
`senderRatchetStates = secretTree.exportSenderStates()`; add a sibling field, and
have `restore()` call `importSkippedKeys` next to its existing
`importSenderStates`.
**3. Keep old state readable.** The persisted state is a TLS-encoded struct that
existing installs already hold, so the new field has to be optional: absent means
an empty map, which is exactly the behaviour today. Without that, every device
with a stored group is broken by the upgrade.
**4. Consumed-generation replay protection.** `consumedGenerations` guards
against a replayed message re-using a cached key. It is in-memory too, so it
should travel with the skipped keys or the guard weakens across restarts. Worth
deciding deliberately rather than by omission.
A test worth having with it: save and restore a group between the two messages
of an out-of-order pair, and assert the older one still decrypts. That is the
property, and it is invisible to any test that keeps one instance alive.
### Getting the change into this build
Quartz is **not** a local fork. It is `com.vitorpamplona.quartz:quartz`, pinned
in `gradle/libs.versions.toml` and resolved from mavenCentral;
`settings.gradle.kts` only `includeBuild`s `lightning-kmp-app`. Nothing in this
repository can change it.
There is a full amethyst clone at `~/Documents/development/nostr/amethyst` whose
`SecretTree.kt` was byte-identical to published 1.14.0 when this was written, so
the patch itself is a small delta against a known-good base. Landing it means one
of:
- **Upstream it.** It is a genuine RFC 9420 conformance gap and affects any
client that reloads group state per message, which is the ordinary shape for a
mobile app. Slowest, and the only option that leaves this repo's build
reproducible.
- **Patch the clone and publish to mavenLocal**, then add `mavenLocal()` here and
pin the patched version. Fast, but the build then depends on a patched crypto
library built from one machine's filesystem.
- **Wire quartz as a composite build**, the way `lightning-kmp-app` is. Same
coupling to a path outside the repo, but the source is at least visible.
## What this app does in the meantime
`MlsGroupCache` keeps a room's `MlsGroup` instance alive between messages instead
of rebuilding it from stored state each time, so `skippedKeys` survives for as
long as the process does. The inbound path in `NostrDao` goes through it.
This covers the case that actually bites — a burst arriving in one sync, decrypted
one after another against the same tree — and it is what makes the flows in the
table above work.
It is not the fix, and it is worth being precise about what it leaves broken:
- **A restart loses the cache.** Messages skipped before the app closed cannot be
read after it reopens.
- **Another writer invalidates it.** Sending a message advances the sender ratchet
and saves the room's state; adding a member does too. The cache reuses its
instance only while the stored state is still exactly what it last wrote, and
rebuilds otherwise — dropping the skipped keys at that point, exactly as before.
- **Nothing helps a long reorder.** A message the relay holds back until after a
restart or an outbound send is gone.
The staleness check is what keeps the cache from being *worse* than no cache: a
group that has been overtaken by another writer is never carried on with, so the
fallback is always the old behaviour rather than a diverged ratchet.