Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the next phases follow. Schema only: a session still signs exactly one event, the wire is byte-identical, and every existing test passes on the moved columns. ## What moved, and why it had to A batch of k events is k independent FROST instances sharing a signer set, not one signature over k messages. That is forced rather than chosen: a Schnorr partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under one nonce R give two equations in one unknown and the secret share falls out. So the five columns that enter that equation -- unsignedEventJson, eventId, nonceRandom, aggregatedNonce, signature -- move to a child table keyed (sessionId, itemIndex). What stays on FrostSigningSession is everything outside it: the ceremony, the threshold, the derivation path, the signer set, and the one approval. itemIndex is protocol rather than presentation -- nonces and partial signatures are joined positionally against it -- so getItems() orders by it and nothing re-sorts. Spelled itemIndex rather than index to keep hand-written queries free of backticks. No itemCount column. The count is a COUNT(*), for the same reason signerIds is derived from the ceremony's participant order rather than stored: a denormalised count is one more thing that can disagree with the rows. ## Migration 9 -> 10 Manual, not auto: Room can create the table and drop the columns but cannot copy between them, and the copy is the whole point. A session in flight at upgrade holds its nonce seed and the aggregate it is already signing against, and neither can be regenerated -- losing either makes the next pass derive a different nonce for the same message and publish a second partial signature over it, which is the extraction case. Both are copied verbatim into item 0, so an in-flight session resumes as though nothing happened. Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires cascades -- with foreign keys enforced the rebuild would delete every signer message and every item just written. Whether it does depends on Room disabling foreign keys around migrations, which is not worth depending on when DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed, unconstrained columns; these five qualify, and getRoomDatabase pins BundledSQLiteDriver on every platform. ## Invariants established here for the phases that follow - signerIds and every item's aggregatedNonce are one write-once unit, applied by applyAggregate() -- items first in one transaction, then the session, so "some items aggregated" is unreachable and signerIds != null stays the gate. - Signatures likewise, via applySignatures(); isSigned() counts rows instead of reading a flag. - complete() verifies every signature before applying any event, so a batch is all-or-nothing rather than half-filed. - itemsOver() gives each item its own 32 bytes of seed. Independent seeds mean an off-by-one in index handling produces a session that fails to aggregate rather than one that signs two messages under a single nonce. signedEvent() and isAwaitingApproval() now take the item(s) rather than the session, which propagates to the repository, the view model and the screen. advance() reads items.first() and Phase 2 turns that into a loop. ## Tests - FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert replacing rather than accumulating, signed-item counting, cascade delete. - FrostSigningItemMigrationJvmTest (new): the backfill against a real v9 database, asserting the seed and aggregate values survive -- not merely that a row appeared -- plus the exact column lists Room will check at open time. - 338 jvmTest and 217 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
342 lines
16 KiB
Kotlin
342 lines
16 KiB
Kotlin
package press.mantra.compose.database
|
|
|
|
import androidx.room3.ColumnTypeConverters
|
|
import androidx.room3.AutoMigration
|
|
import androidx.room3.Database
|
|
import androidx.room3.RoomDatabase
|
|
import androidx.room3.immediateTransaction
|
|
import androidx.room3.useWriterConnection
|
|
import press.mantra.compose.database.converters.MantraConverters
|
|
import press.mantra.compose.database.dao.BroadcastNostrEventReceiptDao
|
|
import press.mantra.compose.database.dao.BroadcastNostrEventRequestDao
|
|
import press.mantra.compose.database.dao.ChatMessageBroadcastNostrEventReceiptRelationDao
|
|
import press.mantra.compose.database.dao.ChatMessageBroadcastNostrEventRequestRelationDao
|
|
import press.mantra.compose.database.dao.ChatMessageDao
|
|
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
|
|
import press.mantra.compose.database.dao.GroupKeyStateDao
|
|
import press.mantra.compose.database.dao.InReplyToRelationDao
|
|
import press.mantra.compose.database.dao.MantraArtifactDao
|
|
import press.mantra.compose.database.dao.MantraArtifactVersionDao
|
|
import press.mantra.compose.database.dao.MantraChapterDao
|
|
import press.mantra.compose.database.dao.MantraChunkDao
|
|
import press.mantra.compose.database.dao.MantraDao
|
|
import press.mantra.compose.database.dao.MantraDialectDao
|
|
import press.mantra.compose.database.dao.MantraTranslationArtifactVersionDao
|
|
import press.mantra.compose.database.dao.MantraTranslationArtifactVersionContributorDao
|
|
import press.mantra.compose.database.dao.MantraTranslationChapterDao
|
|
import press.mantra.compose.database.dao.MantraTranslationChapterContributorDao
|
|
import press.mantra.compose.database.dao.MantraTranslationChunkDao
|
|
import press.mantra.compose.database.dao.MantraTranslationDao
|
|
import press.mantra.compose.database.dao.MantraTranslationContributorDao
|
|
import press.mantra.compose.database.dao.MarmotCommitResultDao
|
|
import press.mantra.compose.database.dao.MarmotOutboundDao
|
|
import press.mantra.compose.database.dao.MarmotGroupEventDao
|
|
import press.mantra.compose.database.dao.MarmotInnerEventDao
|
|
import press.mantra.compose.database.dao.MarmotKeyPackageBundleDao
|
|
import press.mantra.compose.database.dao.MarmotKeyPackageDao
|
|
import press.mantra.compose.database.dao.MarmotRetainedEpochSecretDao
|
|
import press.mantra.compose.database.dao.MentionDao
|
|
import press.mantra.compose.database.dao.NegentropySynchronizeRequestDao
|
|
import press.mantra.compose.database.dao.NegentropySynchronizeResultDao
|
|
import press.mantra.compose.database.dao.NostrDao
|
|
import press.mantra.compose.database.dao.NostrEventDao
|
|
import press.mantra.compose.database.dao.NostrEventRelayDao
|
|
import press.mantra.compose.database.dao.NostrNip17Dao
|
|
import press.mantra.compose.database.dao.ParticipantDao
|
|
import press.mantra.compose.database.dao.PostDao
|
|
import press.mantra.compose.database.dao.ProfileDao
|
|
import press.mantra.compose.database.dao.QuotedRelationDao
|
|
import press.mantra.compose.database.dao.ReactionDao
|
|
import press.mantra.compose.database.dao.RecentSearchDao
|
|
import press.mantra.compose.database.dao.RelayDao
|
|
import press.mantra.compose.database.dao.RepostedRelationDao
|
|
import press.mantra.compose.database.dao.SynchronizeNostrEventRequestDao
|
|
import press.mantra.compose.database.dao.SynchronizeNostrEventResultDao
|
|
import press.mantra.compose.database.dao.UnsignedNostrEventDao
|
|
import press.mantra.compose.database.dao.ZapDao
|
|
import press.mantra.compose.database.model.BroadcastNostrEventReceipt
|
|
import press.mantra.compose.database.model.BroadcastNostrEventRequest
|
|
import press.mantra.compose.database.model.ChatMessage
|
|
import press.mantra.compose.database.model.ChatMessageBroadcastNostrEventReceiptRelation
|
|
import press.mantra.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation
|
|
import press.mantra.compose.database.model.ChatMessageNostrEventRelation
|
|
import press.mantra.compose.database.model.ChatRoom
|
|
import press.mantra.compose.database.model.Connection
|
|
import press.mantra.compose.database.model.GiftWrapMessage
|
|
import press.mantra.compose.database.model.GiftWrapPayload
|
|
import press.mantra.compose.database.model.GiftWrapSeal
|
|
import press.mantra.compose.database.model.GroupKeyState
|
|
import press.mantra.compose.database.model.InReplyToRelation
|
|
import press.mantra.compose.database.model.MantraArtifact
|
|
import press.mantra.compose.database.model.MantraArtifactVersion
|
|
import press.mantra.compose.database.model.MantraChapter
|
|
import press.mantra.compose.database.model.MantraChunk
|
|
import press.mantra.compose.database.model.MantraDialect
|
|
import press.mantra.compose.database.model.MantraTranslation
|
|
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
|
import press.mantra.compose.database.model.MantraTranslationArtifactVersionContributor
|
|
import press.mantra.compose.database.model.MantraTranslationChapter
|
|
import press.mantra.compose.database.model.MantraTranslationChapterContributor
|
|
import press.mantra.compose.database.model.MantraTranslationChunk
|
|
import press.mantra.compose.database.model.MantraTranslationContributor
|
|
import press.mantra.compose.database.model.MarmotCommitResult
|
|
import press.mantra.compose.database.model.MarmotGroupEvent
|
|
import press.mantra.compose.database.model.MarmotInnerEvent
|
|
import press.mantra.compose.database.model.MarmotKeyPackage
|
|
import press.mantra.compose.database.model.MarmotKeyPackageBundle
|
|
import press.mantra.compose.database.model.MarmotRetainedEpochSecret
|
|
import press.mantra.compose.database.model.Mention
|
|
import press.mantra.compose.database.model.NegentropySynchronizeRequest
|
|
import press.mantra.compose.database.model.NegentropySynchronizeResult
|
|
import press.mantra.compose.database.model.NostrEvent
|
|
import press.mantra.compose.database.model.NostrEventRelay
|
|
import press.mantra.compose.database.model.Participant
|
|
import press.mantra.compose.database.model.Post
|
|
import press.mantra.compose.database.model.Profile
|
|
import press.mantra.compose.database.model.QuotedRelation
|
|
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.FrostSigningItem
|
|
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
|
|
import press.mantra.compose.database.model.SynchronizeNostrEventResult
|
|
import press.mantra.compose.database.model.UnsignedNostrEvent
|
|
import press.mantra.compose.database.model.Zap
|
|
import kotlin.time.Instant
|
|
|
|
val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
|
|
|
@Database(
|
|
entities = [
|
|
BroadcastNostrEventReceipt::class,
|
|
BroadcastNostrEventRequest::class,
|
|
Connection::class,
|
|
ChatMessage::class,
|
|
ChatMessageBroadcastNostrEventRequestRelation::class,
|
|
ChatMessageBroadcastNostrEventReceiptRelation::class,
|
|
ChatMessageNostrEventRelation::class,
|
|
ChatRoom::class,
|
|
DkgParticipantMessage::class,
|
|
DkgSession::class,
|
|
FrostSignerMessage::class,
|
|
FrostSigningItem::class,
|
|
FrostSigningSession::class,
|
|
GiftWrapMessage::class,
|
|
GiftWrapSeal::class,
|
|
GiftWrapPayload::class,
|
|
GroupKeyState::class,
|
|
InReplyToRelation::class,
|
|
MantraArtifact::class,
|
|
MantraArtifactVersion::class,
|
|
MantraChapter::class,
|
|
MantraChunk::class,
|
|
MantraDialect::class,
|
|
MantraTranslation::class,
|
|
MantraTranslationArtifactVersion::class,
|
|
MantraTranslationArtifactVersionContributor::class,
|
|
MantraTranslationChapter::class,
|
|
MantraTranslationChapterContributor::class,
|
|
MantraTranslationChunk::class,
|
|
MantraTranslationContributor::class,
|
|
MarmotCommitResult::class,
|
|
MarmotGroupEvent::class,
|
|
MarmotInnerEvent::class,
|
|
MarmotKeyPackage::class,
|
|
MarmotKeyPackageBundle::class,
|
|
MarmotRetainedEpochSecret::class,
|
|
Mention::class,
|
|
NegentropySynchronizeRequest::class,
|
|
NegentropySynchronizeResult::class,
|
|
NostrEvent::class,
|
|
NostrEventRelay::class,
|
|
Participant::class,
|
|
Post::class,
|
|
Profile::class,
|
|
QuotedRelation::class,
|
|
Reaction::class,
|
|
RecentSearch::class,
|
|
Relay::class,
|
|
RepostedRelation::class,
|
|
SynchronizeNostrEventRequest::class,
|
|
SynchronizeNostrEventResult::class,
|
|
UnsignedNostrEvent::class,
|
|
Zap::class
|
|
],
|
|
version = 10,
|
|
autoMigrations = [
|
|
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
|
// generate the migration itself — nothing existing changes shape.
|
|
AutoMigration(from = 1, to = 2),
|
|
// v3 adds the three nullable approval timestamps to DkgSession. Nullable
|
|
// additions need no default and drop no data, so Room generates this one
|
|
// too. Rituals already in flight come back with all three null, which reads
|
|
// as "not approved yet" and simply asks the member for each step.
|
|
AutoMigration(from = 2, to = 3),
|
|
// v4 changes no schema at all -- it rewrites `dkgApprovalNeeded` chat rows
|
|
// into one type per ritual step. Data, not shape, so it is a manual
|
|
// migration passed to the builder rather than an entry here. See
|
|
// MIGRATION_3_4.
|
|
//
|
|
// 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),
|
|
// v7 adds the GroupKeyState table, which records what shared key a room
|
|
// signs with instead of leaving it to be rederived. A new table is a
|
|
// shape Room migrates itself. Rooms created before this have no row and
|
|
// fall back to the rederivation scan in FrostSigningManager.completedKey,
|
|
// which is why that scan stays.
|
|
AutoMigration(from = 6, to = 7),
|
|
// v8 adds a nullable direct message recipient to MarmotInnerEvent and
|
|
// ChatMessage. Nullable additions need no default and drop no data, so Room
|
|
// generates this one. Rows written before it come back null, which reads as
|
|
// "not a direct message" -- the only answer that is true of all of them.
|
|
AutoMigration(from = 7, to = 8),
|
|
// v9 adds the nullable FrostSigningSession.derivationPath, which says
|
|
// what key off the ceremony's the session signs as. Sessions started
|
|
// before it read back null, meaning the untweaked threshold key -- which
|
|
// is what they were signing as, so one caught mid-flight finishes the way
|
|
// it began rather than switching keys between two of its own rounds.
|
|
AutoMigration(from = 8, to = 9)
|
|
// v10 moves FrostSigningSession's five per-event columns onto the new
|
|
// FrostSigningItem table, so one session can sign a batch. It copies
|
|
// before it drops, which no AutoMigration can express -- see
|
|
// MIGRATION_9_10, and the note there on why an in-flight session losing
|
|
// its nonce seed would be worse than losing the session.
|
|
]
|
|
)
|
|
@ColumnTypeConverters(MantraConverters::class)
|
|
abstract class MantraDatabase: RoomDatabase() {
|
|
abstract fun broadcastNostrEventReceiptDao(): BroadcastNostrEventReceiptDao
|
|
abstract fun broadcastNostrEventRequestDao(): BroadcastNostrEventRequestDao
|
|
|
|
abstract fun chatMessageDao(): ChatMessageDao
|
|
|
|
abstract fun chatMessageBroadcastNostrEventRequestRelationDao(): ChatMessageBroadcastNostrEventRequestRelationDao
|
|
|
|
abstract fun chatMessageBroadcastNostrEventReceiptRelationDao(): ChatMessageBroadcastNostrEventReceiptRelationDao
|
|
|
|
abstract fun chatMessageNostrEventRelationDao(): ChatMessageNostrEventRelationDao
|
|
|
|
abstract fun chatRoomDao(): ChatRoomDao
|
|
|
|
abstract fun dkgSessionDao(): DkgSessionDao
|
|
|
|
abstract fun frostSigningSessionDao(): FrostSigningSessionDao
|
|
|
|
abstract fun groupKeyStateDao(): GroupKeyStateDao
|
|
|
|
abstract fun connectionDao(): ConnectionDao
|
|
|
|
abstract fun giftWrapMessageDao(): GiftWrapMessageDao
|
|
|
|
abstract fun giftWrapSealDao(): GiftWrapSealDao
|
|
|
|
abstract fun giftWrapPayloadDao(): GiftWrapPayloadDao
|
|
|
|
abstract fun inReplyToRelationDao(): InReplyToRelationDao
|
|
|
|
abstract fun mantraDao(): MantraDao
|
|
|
|
abstract fun mantraArtifactDao(): MantraArtifactDao
|
|
|
|
abstract fun mantraArtifactVersionDao(): MantraArtifactVersionDao
|
|
|
|
abstract fun mantraChapterDao(): MantraChapterDao
|
|
|
|
abstract fun mantraChunkDao(): MantraChunkDao
|
|
|
|
abstract fun mantraDialectDao(): MantraDialectDao
|
|
|
|
abstract fun mantraTranslationArtifactVersionDao(): MantraTranslationArtifactVersionDao
|
|
|
|
abstract fun mantraTranslationArtifactVersionContributorDao(): MantraTranslationArtifactVersionContributorDao
|
|
|
|
abstract fun mantraTranslationChapterDao(): MantraTranslationChapterDao
|
|
|
|
abstract fun mantraTranslationChapterContributorDao(): MantraTranslationChapterContributorDao
|
|
|
|
abstract fun mantraTranslationChunkDao(): MantraTranslationChunkDao
|
|
|
|
abstract fun mantraTranslationDao(): MantraTranslationDao
|
|
|
|
abstract fun mantraTranslationContributorDao(): MantraTranslationContributorDao
|
|
|
|
abstract fun marmotCommitResultDao(): MarmotCommitResultDao
|
|
|
|
abstract fun marmotGroupEventDao(): MarmotGroupEventDao
|
|
|
|
abstract fun marmotInnerEventDao(): MarmotInnerEventDao
|
|
|
|
abstract fun marmotKeyPackageBundleDao(): MarmotKeyPackageBundleDao
|
|
|
|
abstract fun marmotKeyPackageDao(): MarmotKeyPackageDao
|
|
|
|
abstract fun marmotOutboundDao(): MarmotOutboundDao
|
|
|
|
abstract fun marmotRetainedEpochSecretDao(): MarmotRetainedEpochSecretDao
|
|
|
|
abstract fun mentionDao(): MentionDao
|
|
|
|
abstract fun negentropySynchronizeRequestDao(): NegentropySynchronizeRequestDao
|
|
|
|
abstract fun negentropySynchronizeResultDao(): NegentropySynchronizeResultDao
|
|
|
|
abstract fun nostrDao(): NostrDao
|
|
|
|
abstract fun nostrEventDao(): NostrEventDao
|
|
|
|
abstract fun nostrEventRelayDao(): NostrEventRelayDao
|
|
|
|
abstract fun nostrNip17Dao(): NostrNip17Dao
|
|
|
|
abstract fun participantDao(): ParticipantDao
|
|
|
|
abstract fun postDao(): PostDao
|
|
|
|
abstract fun profileDao(): ProfileDao
|
|
|
|
abstract fun quotedRelationDao(): QuotedRelationDao
|
|
|
|
abstract fun reactionDao(): ReactionDao
|
|
|
|
abstract fun recentSearchDao(): RecentSearchDao
|
|
|
|
abstract fun relayDao(): RelayDao
|
|
|
|
abstract fun repostedRelationDao(): RepostedRelationDao
|
|
|
|
abstract fun synchronizeNostrEventRequestDao(): SynchronizeNostrEventRequestDao
|
|
|
|
abstract fun synchronizeNostrEventResultDao(): SynchronizeNostrEventResultDao
|
|
|
|
abstract fun unsignedNostrEventDao(): UnsignedNostrEventDao
|
|
abstract fun zapDao(): ZapDao
|
|
|
|
suspend fun wipeData() {
|
|
useWriterConnection { transactor ->
|
|
transactor.immediateTransaction {
|
|
// TODO: Actually delete the data...
|
|
}
|
|
}
|
|
}
|
|
} |