diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json new file mode 100644 index 00000000..0d184295 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/10.json @@ -0,0 +1,5443 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "ab781e3489c1258d2dc39c2b2f35cb61", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab781e3489c1258d2dc39c2b2f35cb61')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index 9baaaeb7..4f62d523 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -106,6 +106,7 @@ 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 @@ -130,6 +131,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) DkgParticipantMessage::class, DkgSession::class, FrostSignerMessage::class, + FrostSigningItem::class, FrostSigningSession::class, GiftWrapMessage::class, GiftWrapSeal::class, @@ -172,7 +174,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 9, + version = 10, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -215,6 +217,11 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // 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) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt index 50bf1dbc..92b31069 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt @@ -3,6 +3,7 @@ package press.mantra.compose.database.builder import androidx.room3.RoomDatabase import androidx.sqlite.driver.bundled.BundledSQLiteDriver import press.mantra.compose.database.migrations.MIGRATION_3_4 +import press.mantra.compose.database.migrations.MIGRATION_9_10 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -16,9 +17,11 @@ fun getRoomDatabase( builder: RoomDatabase.Builder ): press.mantra.compose.database.MantraDatabase { return builder - // Everything else Room generates itself; this one rewrites rows rather than - // changing shape, which an AutoMigration cannot express. - .addMigrations(MIGRATION_3_4) + // Everything else Room generates itself. These two move data rather than + // only changing shape, which an AutoMigration cannot express: 3->4 rewrites + // chat rows, and 9->10 copies a session's per-event columns onto the items + // table before dropping them. + .addMigrations(MIGRATION_3_4, MIGRATION_9_10) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt index 0b68a2b2..bc6ba0d0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt @@ -2,11 +2,13 @@ package press.mantra.compose.database.dao import androidx.room3.Dao import androidx.room3.Query +import androidx.room3.Transaction 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.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession @Dao @@ -36,6 +38,46 @@ interface FrostSigningSessionDao { @Upsert suspend fun upsert(frostSignerMessage: FrostSignerMessage) + /** + * A session's events, in the order the batch fixed at proposal. + * + * Every join of nonces and partial signatures is positional, so this + * ordering is part of the protocol rather than a presentation choice — + * two devices reading a batch in different orders aggregate against + * different messages. + */ + @Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC") + suspend fun getItems(sessionId: String): List + + @Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId ORDER BY itemIndex ASC") + fun observeItems(sessionId: String): Flow> + + @Query("SELECT * FROM FrostSigningItem WHERE sessionId = :sessionId AND itemIndex = :itemIndex") + suspend fun getItem(sessionId: String, itemIndex: Int): FrostSigningItem? + + @Upsert + suspend fun upsert(frostSigningItem: FrostSigningItem) + + /** + * Writes a whole batch's worth of items at once. + * + * A transaction rather than a loop because the coordinator's two broadcasts + * each settle every item together: a half-written aggregate would leave the + * session gated on `signerIds` while some items had no nonce to aggregate + * against, which is a stall no message can clear. + */ + @Transaction + @Upsert + suspend fun upsertItems(items: List) + + /** How many events this session signs. Derived rather than stored — see [FrostSigningItem]. */ + @Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId") + suspend fun countItems(sessionId: String): Int + + /** How many of them the group has finished. Equal to [countItems] once it is done. */ + @Query("SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :sessionId AND signature IS NOT NULL") + suspend fun countSignedItems(sessionId: String): Int + @Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC") suspend fun getMessagesByKind(sessionId: String, kind: Kind): List diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt new file mode 100644 index 00000000..3b247268 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigration.kt @@ -0,0 +1,90 @@ +package press.mantra.compose.database.migrations + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Moves a signing session's per-event columns onto `FrostSigningItem`, so a + * session can sign more than one event. + * + * Not an `AutoMigration`. Room can create the table and it can drop the columns, + * but it cannot copy between them, and this migration is entirely about the copy. + * + * ### Why the backfill has to be exact + * + * A session in flight at upgrade time holds two things that cannot be + * regenerated: `nonceRandom`, the seed its secret nonce is derived from, and + * `aggregatedNonce`, the aggregate its partial signature is made against. Lose + * the seed and the next pass derives a *different* nonce for the same message, + * then publishes a second partial signature over it -- two partial signatures, + * one message, two nonces, which is precisely how a secret share is extracted. + * Lose the aggregate and the session republishes against a new one, with the + * same result. + * + * So this copies them verbatim into item 0, and an in-flight session resumes as + * though nothing happened. Dropping the columns and letting sessions start over + * would have been the one dangerous way to write this migration. + * + * `signerIds` stays on the session: it is shared by every item of a batch, and + * an upgraded session keeps whichever set it had chosen. + * + * ### Why `DROP COLUMN` rather than a table rebuild + * + * The usual way to remove columns in SQLite -- create a new table, copy, drop + * the old one, rename -- is unsafe here and quietly so. `FrostSignerMessage` and + * the new `FrostSigningItem` both reference `FrostSigningSession(id)` + * `ON DELETE CASCADE`, and `DROP TABLE` fires cascades: with foreign keys + * enforced it would delete every signer message and every item this migration + * had just written. It would depend entirely on Room having turned foreign keys + * off around the migration, which is not a thing worth depending on when there + * is an alternative that cannot go wrong. + * + * `ALTER TABLE ... DROP COLUMN` is that alternative. It needs SQLite 3.35, and + * the columns must be free of indices and constraints -- these five are, and + * `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version + * is ours rather than the host's. The table, its foreign key and both its + * indices are left alone. + */ +val MIGRATION_9_10 = object : Migration(9, 10) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `FrostSigningItem` (" + + "`sessionId` TEXT NOT NULL, " + + "`itemIndex` INTEGER NOT NULL, " + + "`unsignedEventJson` TEXT NOT NULL, " + + "`eventId` TEXT NOT NULL, " + + "`nonceRandom` TEXT NOT NULL, " + + "`aggregatedNonce` TEXT, " + + "`signature` TEXT, " + + "PRIMARY KEY(`sessionId`, `itemIndex`), " + + "FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` " + + "ON `FrostSigningItem` (`sessionId`)" + ) + + // Every existing session signed exactly one event, so every one of them + // becomes a batch of one at index 0. + connection.execSQL( + "INSERT INTO `FrostSigningItem` " + + "(`sessionId`, `itemIndex`, `unsignedEventJson`, `eventId`, " + + "`nonceRandom`, `aggregatedNonce`, `signature`) " + + "SELECT `id`, 0, `unsignedEventJson`, `eventId`, " + + "`nonceRandom`, `aggregatedNonce`, `signature` " + + "FROM `FrostSigningSession`" + ) + + listOf( + "unsignedEventJson", + "eventId", + "nonceRandom", + "aggregatedNonce", + "signature", + ).forEach { column -> + connection.execSQL("ALTER TABLE `FrostSigningSession` DROP COLUMN `$column`") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt new file mode 100644 index 00000000..8ebc6534 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningItem.kt @@ -0,0 +1,88 @@ +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 + +/** + * One event a signing session signs, and everything about it that cannot be + * shared with the others. + * + * A session signs a *batch*: one ceremony, one signer set, one approval, and `n` + * events. What separates them is forced by FROST 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. Every item therefore carries its own nonce material and its own result, + * and the only things a batch may hold in common are the ones that do not enter + * that equation -- see [FrostSigningSession]. + * + * These five columns lived on [FrostSigningSession] until the schema moved to + * v10, which is why a session created before it reads back as exactly one item + * at [itemIndex] 0. + */ +@Entity( + primaryKeys = ["sessionId", "itemIndex"], + foreignKeys = [ + ForeignKey( + entity = FrostSigningSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("sessionId"), + ], +) +data class FrostSigningItem( + val sessionId: String, + + /** + * Position in the batch, from zero. + * + * Load-bearing rather than cosmetic: it is the order every device joins + * nonces and partial signatures in, so two devices that disagree about it + * aggregate against different messages and produce a signature nobody can + * verify. Fixed by the proposal and never re-sorted. + * + * Named `itemIndex` rather than `index` because `index` needs quoting in + * every hand-written query it appears in, and one missing backtick is a + * compile error at best. + */ + val itemIndex: Int, + + /** + * 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 this item to one message -- which is what makes reusing + * [nonceRandom] safe, exactly as it was when a session signed one event. + */ + val eventId: HexKey, + + /** Secret. 32 bytes of fresh randomness, the seed for this item's nonce. */ + val nonceRandom: HexKey, + + /** + * The coordinator's `AggregatedNonce` for this item once it arrives, hex. + * + * Written once, and written for every item of the batch in one transaction + * alongside [FrostSigningSession.signerIds] -- so "some items aggregated" is + * a state that cannot be reached, and `signerIds != null` is the one gate + * the rest of the session reads. + */ + val aggregatedNonce: HexKey? = null, + + /** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */ + val signature: HexKey? = null, +) { + /** Whether the group has produced this item's signature. */ + fun isSigned(): Boolean = signature != null +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt index e823ec1f..e1d43bf5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt @@ -21,22 +21,40 @@ import press.mantra.compose.managers.SharedKeyDerivation * 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 + * ### One session, several events * - * [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. + * A session signs a batch of one or more events, held as [FrostSigningItem] + * rows. What lives here is what the whole batch shares, and the split is not a + * matter of taste: an item's nonce, message and signature are the terms of + * `s = k + e·x` with `e = H(R‖P‖m)`, so sharing any of them across two messages + * is how a secret share is extracted. Everything on this row is outside that + * equation. + * + * | shared, and so here | per item, and so on [FrostSigningItem] | + * |---|---| + * | ceremony, threshold, participant count | the unsigned event and its id | + * | [signerIds] and the signer set | nonce seed, and the nonce from it | + * | [derivationPath] and its tweak cache | aggregated nonce | + * | [signApprovedAt] -- one decision | the signature | + * + * ### The nonce, and why one item means one message + * + * [FrostSigningItem.nonceRandom] is secret, and regenerating this device's nonce + * from it is safe for exactly one reason: an item 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. + * - [FrostSigningItem.eventId] is written when the session is created, and a + * proposal that disagrees with the batch it already holds is rejected rather + * than applied. + * - [FrostSigningItem.aggregatedNonce] and [signerIds] are written once, and + * written together. A second, different signer set for the same session is + * ignored, not honoured. */ @Entity( foreignKeys = [ @@ -92,38 +110,16 @@ data class FrostSigningSession( * it is running in. Null is the honest answer for a room that is not derived * from the key at all, and is what sessions predating this column read back * as -- both of which signed as the threshold key itself. + * + * Shared by the whole batch: every item of a session signs as the same room. */ val derivationPath: String? = null, 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, /** @@ -131,10 +127,12 @@ data class FrostSigningSession( * 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. + * One gate rather than the DKG's three, and one gate for the whole batch + * rather than one per item. What a signer is consenting to is the events, + * and the events are 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. That holds for a batch only as long + * as the member can actually see every event in it before answering. */ val signApprovedAt: Instant? = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt index abd6ed08..cc93b0f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -4,10 +4,12 @@ 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 com.vitorpamplona.quartz.nip01Core.signers.EventTemplate 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.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.FrostSigningStage @@ -29,6 +31,12 @@ class DatabaseFrostSigningRepository( override fun observeMessages(sessionId: String): Flow> = database.frostSigningSessionDao().observeMessages(sessionId) + override fun observeItems(sessionId: String): Flow> = + database.frostSigningSessionDao().observeItems(sessionId) + + override suspend fun getItems(sessionId: String): List = + database.frostSigningSessionDao().getItems(sessionId) + override suspend fun getSessionById(sessionId: String): FrostSigningSession? = database.frostSigningSessionDao().getSessionById(sessionId) @@ -49,7 +57,7 @@ class DatabaseFrostSigningRepository( kind: Kind, tags: Array>, content: String - ): FrostSigningSession? = try { + ): FrostSigningSession? = proposing(localChatRoom) { FrostSigningManager.proposeSigning( database = database, localChatRoom = localChatRoom, @@ -58,10 +66,33 @@ class DatabaseFrostSigningRepository( tags = tags, content = content ) + } + + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List> + ): FrostSigningSession? = proposing(localChatRoom) { + FrostSigningManager.proposeSigningBatch( + database = database, + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + events = events + ) + } + + /** + * Proposing throws when the group has no key, when this device was not in the + * ceremony, or when a batch is empty or over the cap. All four are states the + * UI is supposed to have checked for, so they become a null the caller + * reports rather than a crash. + */ + private inline fun proposing( + localChatRoom: LocalChatRoom, + propose: () -> FrostSigningSession + ): FrostSigningSession? = try { + propose() } 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 } @@ -92,8 +123,8 @@ class DatabaseFrostSigningRepository( } } - override fun signedEvent(session: FrostSigningSession): Event? = - FrostSigningManager.signedEvent(session) + override fun signedEvents(items: List): List = + FrostSigningManager.signedEvents(items) companion object { private const val TAG = "DatabaseFrostSigningRepository" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index cf20fd29..93386f5a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -110,7 +110,8 @@ object ChillDkgRitualManager { memberPublicKeys(localChatRoom).size >= MINIMUM_PARTICIPANTS /** - * Opens a ritual, making this device the coordinator. + * Opens a ritual, making this device the coordinator. A room already running + * one gets that one back rather than a second. * * Throws when the group or the threshold cannot support one; the UI checks * both before offering the button, so reaching either is a bug rather than a @@ -123,6 +124,26 @@ object ChillDkgRitualManager { nostrPrivateKey: ByteArray, threshold: Int ): DkgSession { + // A room runs one ceremony at a time, and this is reachable twice now that + // a robust group opens one as it is created: the room id is derived from + // its members, so making the same group again lands back in the same room + // and asks again. A second proposal is a second participant set for every + // member to reconcile, and the key the first one produced would be left + // with nothing pointing at it. A failed ritual is not running, and is + // there to be replaced. + // + // Checked before the arguments are, because a running ritual makes the + // requested threshold moot -- it settled that question when it opened. + database.dkgSessionDao().getLatestSessionForChatRoom(localChatRoom.chatRoom.id) + ?.takeIf { it.stage != DkgRitualStage.FAILED } + ?.let { running -> + logger.i( + "Room ${localChatRoom.chatRoom.id} is already running ritual " + + "${running.id}; not opening another" + ) + return running + } + // Built the way a receiver rebuilds it from the proposal — the p-tags // `broadcast` writes, plus this device — so both sides count the same `n` // even if the room's own rows have drifted. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 6927d41e..28521d5e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -7,6 +7,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.utils.RandomInstance import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 @@ -23,6 +24,7 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.ChatMessage 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.GroupKeyState import press.mantra.compose.database.model.MarmotInnerEvent @@ -64,6 +66,20 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * The path comes from the room, never from a proposal -- [signingPath] -- because * it decides which key the group signs as. * + * ### It signs a batch + * + * A session carries one or more events as [FrostSigningItem] rows, and runs one + * FROST instance per event in lockstep: one signer set, one aggregate per item, + * one partial signature per item per signer, one approval. Four group events + * whatever the size, instead of four per event. + * + * That is all the batching there is, and all there can be. A Schnorr partial + * signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under one + * nonce give two equations in one unknown and the secret share falls out; a + * batch shares only what is outside that equation. Every item has its own seed, + * its own aggregate and its own `Session`, and [joinPayload] is the only place + * they travel together. + * * Three things are genuinely different from a ceremony, and each of them is why * this is a separate manager rather than another branch of that one. * @@ -79,8 +95,8 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * * `SecretNonce` cannot be serialised and refuses to be used twice. Storing the * randomness it is derived from and regenerating on demand is the only way a - * signing session can survive the app closing -- and it is safe only because a - * session signs one message and cannot be made to sign another. See + * signing session can survive the app closing -- and it is safe only because an + * item signs one message and cannot be made to sign another. See * [FrostSigningSession] for the two rules that hold that in place; both are * enforced here, in [acceptProposal] and in [record]. * @@ -88,14 +104,33 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * * A DKG asks three times because each step publishes something different and * commits the member to something different. Here every step serves one - * decision -- sign this event or do not -- and the event is fixed before the - * member is asked. A second prompt would be the same question again. + * decision -- sign these events or do not -- and they are fixed before the + * member is asked. A second prompt would be the same question again. That holds + * for a batch only as long as the member can see every event in it before + * answering, which is the screen's side of the bargain. */ object FrostSigningManager { private const val TAG = "FrostSigningManager" private val logger = Logger.withTag(TAG) + /** + * The most events one session will sign. + * + * Enforced when proposing, and again -- independently -- when a proposal + * arrives. The second check is the one that matters. A proposal is the only + * place in this protocol where a remote party decides how much work everybody + * else does: k native key generations, k signatures, and a group event + * carrying k payloads, all from a single message. Until batching that was + * bounded by never being more than one. + * + * Sized against what a group event will carry rather than picked round. A + * signer's nonce message is k * 133 bytes of hex and commas and their partial + * message k * 65, neither of which binds; the proposal carries k whole + * events, which does. + */ + const val MAX_BATCH_SIZE: Int = 64 + /** * Opens a signing session, making this device the coordinator. * @@ -123,7 +158,46 @@ object FrostSigningManager { content: String, key: DkgSession? = null, createdAt: Long = Clock.System.now().epochSeconds + ): FrostSigningSession = proposeSigningBatch( + database = database, + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + events = listOf(EventTemplate(createdAt, kind, tags, content)), + key = key + ) + + /** + * Opens a session over several events at once, making this device the + * coordinator. + * + * One ceremony, one signer set, one approval and four group events, whatever + * the size -- but k independent FROST instances underneath, because that is + * the only thing a batch can be. See [FrostSigningItem] for why. + * + * A batch is all-or-nothing: if any item cannot be aggregated the session + * fails and none of its events are applied. That makes a batch **only as + * available as its worst item**, so events that do not belong together + * should not be proposed together. + * + * A failed batch is retried by proposing a *new* one, never by re-proposing + * this session. Its items' nonce seeds have already been published against an + * aggregate; reusing any of them for a second attempt would produce two + * partial signatures over one secret nonce, which is how a share is + * extracted. [itemsOver] mints fresh seeds precisely so that a retry is a new + * session by construction. + */ + suspend fun proposeSigningBatch( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List>, + key: DkgSession? = null ): FrostSigningSession { + require(events.isNotEmpty()) { "A signing session must be given something to sign" } + require(events.size <= MAX_BATCH_SIZE) { + "A signing session will sign at most $MAX_BATCH_SIZE events, not ${events.size}" + } + val ceremony = key?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null } ?: completedKey(database, localChatRoom.chatRoom.id) ?: throw IllegalStateException("This group has no shared key to sign with") @@ -132,7 +206,16 @@ object FrostSigningManager { ?: throw IllegalStateException("This device is not a participant in ceremony ${ceremony.id}") val path = signingPath(database, localChatRoom, ceremony) - val unsignedEvent = unsignedEventOf(ceremony, path, kind, tags, content, createdAt) + val unsignedEvents = events.map { template -> + unsignedEventOf( + key = ceremony, + path = path, + kind = template.kind, + tags = template.tags, + content = template.content, + createdAt = template.createdAt + ) + } val sessionId = RandomInstance.bytes(32).toHex() val session = FrostSigningSession( @@ -145,25 +228,24 @@ object FrostSigningManager { participantCount = ceremony.participantCount, signerId = signerId, derivationPath = path?.let(SharedKeyDerivation::formatPath), - unsignedEventJson = unsignedEvent.toJson(), - eventId = unsignedEvent.id, - // Fresh per session, and never reused: this is the seed the device's - // secret nonce is regenerated from for the life of the session. - nonceRandom = RandomInstance.bytes(32).toHex(), // Proposing a signature is already the act of agreeing to it. signApprovedAt = Clock.System.now() ) database.frostSigningSessionDao().upsert(session) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, unsignedEvents)) announceStarted(database, session) - logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${ceremony.id}") + logger.i( + "Proposing signature $sessionId over ${describe(unsignedEvents.size)} " + + "(${unsignedEvents.joinToString { it.id.take(8) }}) with key ${ceremony.id}" + ) broadcast( database = database, localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.PROPOSAL, - content = session.unsignedEventJson, + content = FrostSigningEvents.encodeProposal(unsignedEvents), includeKey = true ) @@ -236,15 +318,17 @@ object FrostSigningManager { userPublicKey: HexKey ): FrostSigningSession? { database.frostSigningSessionDao().getSessionById(sessionId)?.let { existing -> - // The message a session signs is fixed at creation. A second proposal - // under the same id carrying a different event is either a mistake or - // an attempt to get two signatures out of one secret nonce, which is - // how a share is extracted -- so it is refused, not applied. - val proposed = Event.fromJsonOrNull(innerEvent.content) - if (proposed != null && proposed.id != existing.eventId) { + // The events a session signs are fixed at creation. A second proposal + // under the same id carrying different ones is either a mistake or an + // attempt to get two signatures out of one secret nonce, which is how + // a share is extracted -- so it is refused, not applied. + val proposed = FrostSigningEvents.decodeProposal(innerEvent.content) + val signing = database.frostSigningSessionDao().getItems(sessionId).map { it.eventId } + if (proposed != null && signing != proposed.map { it.id }) { logger.w( - "Session $sessionId re-proposed with event ${proposed.id}, " + - "but it is already signing ${existing.eventId}; ignoring" + "Session $sessionId re-proposed with ${describe(proposed.size)} " + + "(${proposed.joinToString { it.id.take(8) }}), but it is already signing " + + "${signing.joinToString { it.take(8) }}; ignoring" ) } return existing @@ -268,9 +352,19 @@ object FrostSigningManager { return null } - val proposed = Event.fromJsonOrNull(innerEvent.content) + val proposed = FrostSigningEvents.decodeProposal(innerEvent.content) if (proposed == null) { - logger.w("Signing proposal $sessionId does not carry an event; dropping") + logger.w("Signing proposal $sessionId does not carry any events; dropping") + return null + } + + // Checked here as well as when proposing, because this is where a remote + // party gets to decide how much work this device does. See [MAX_BATCH_SIZE]. + if (proposed.size > MAX_BATCH_SIZE) { + logger.w( + "Signing proposal $sessionId asks for ${proposed.size} events, " + + "more than the $MAX_BATCH_SIZE this device will sign at once; dropping" + ) return null } @@ -284,20 +378,29 @@ object FrostSigningManager { // could choose the key the group signs as, and every signer would put // their share behind an author none of them checked. val path = signingPath(database, localChatRoom, key) - val unsignedEvent = unsignedEventOf( - key = key, - path = path, - kind = proposed.kind, - tags = proposed.tags, - content = proposed.content, - createdAt = proposed.createdAt - ) - if (unsignedEvent.id != proposed.id) { - logger.w( - "Signing proposal $sessionId carries id ${proposed.id} but its fields hash " + - "to ${unsignedEvent.id}; dropping" + val unsignedEvents = proposed.map { event -> + unsignedEventOf( + key = key, + path = path, + kind = event.kind, + tags = event.tags, + content = event.content, + createdAt = event.createdAt ) - return null + } + + // All of them or none. A batch's length is what every later payload is + // checked against, so quietly dropping one bad event would leave a session + // that rejects every signer's contribution for being the wrong size -- + // a stall with nothing to blame it on. + unsignedEvents.forEachIndexed { index, rebuilt -> + if (rebuilt.id != proposed[index].id) { + logger.w( + "Signing proposal $sessionId carries id ${proposed[index].id} at $index " + + "but its fields hash to ${rebuilt.id}; dropping" + ) + return null + } } val session = FrostSigningSession( @@ -312,15 +415,16 @@ object FrostSigningManager { threshold = key.threshold, participantCount = key.participantCount, signerId = signerId, - derivationPath = path?.let(SharedKeyDerivation::formatPath), - unsignedEventJson = unsignedEvent.toJson(), - eventId = unsignedEvent.id, - nonceRandom = RandomInstance.bytes(32).toHex() + derivationPath = path?.let(SharedKeyDerivation::formatPath) ) database.frostSigningSessionDao().upsert(session) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, unsignedEvents)) announceStarted(database, session) - logger.i("Recorded signing session $sessionId over ${unsignedEvent.id}; awaiting approval") + logger.i( + "Recorded signing session $sessionId over ${describe(unsignedEvents.size)} " + + "(${unsignedEvents.joinToString { it.id.take(8) }}); awaiting approval" + ) announceApprovalNeeded(database, session) replayStoredMessages(database, session) @@ -406,26 +510,22 @@ object FrostSigningManager { return true } - val known = current(database, session).aggregatedNonce != null - // Write-once, and this is the load-bearing one. Signing the same // message twice under one secret nonce against two different // aggregated nonces is exactly how a secret share is extracted, so // a coordinator that sends a second, different signer set is // ignored rather than obeyed. The session stalls; the share does // not leak. - update(database, session) { current -> - if (current.aggregatedNonce == null) { - current.copy( - aggregatedNonce = innerEvent.content, - signerIds = signerIds.joinToString(",") - ) - } else { - current - } - } + // + // `signerIds` is the gate for the whole batch: it and every item's + // aggregated nonce are written together, so a session holding one + // holds all of them. + if (current(database, session).signerIds != null) return true - if (!known) { + val aggregated = splitForSession(database, session, innerEvent.content) + ?: return true + + if (applyAggregate(database, session, signerIds, aggregated)) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -433,17 +533,15 @@ object FrostSigningManager { FrostSigningEvents.SIGNATURE -> { if (!isFromCoordinator(session, innerEvent)) return true - val known = current(database, session).signature != null + // Write-once as a unit, for the same reason the aggregate is: a + // batch that had some of its signatures would be one the group + // could neither finish nor safely retry. + if (isSigned(database, session)) return true - update(database, session) { current -> - if (current.signature == null) { - current.copy(signature = innerEvent.content) - } else { - current - } - } + val signatures = splitForSession(database, session, innerEvent.content) + ?: return true - if (!known) { + if (applySignatures(database, session, signatures)) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -504,7 +602,7 @@ object FrostSigningManager { // there -- while going on to ask would be asking for a decision that // can no longer change anything, and would let a late "don't sign" // abandon a signature that exists. - if (session.signature != null) { + if (isSigned(database, session)) { complete(database, session) return } @@ -526,51 +624,77 @@ object FrostSigningManager { val tweakCache = SharedKeyDerivation .derive(thresholdPublicKey, session.pathIndices()) .cache - val message = ByteVector(session.eventId.hexToByteArray()) val publicShares = key.publicShareList() - // Regenerated rather than stored -- SecretNonce refuses both. Safe - // because the session's message can never change; see the notes on - // FrostSigningSession. - val (secretNonce, publicNonce) = SecretNonce.generate( - sessionRandom = ByteVector32(session.nonceRandom), - secretShare = secretShare, - publicShare = publicShares?.getOrNull(session.signerId), - tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, - message = message, - extraInput = null - ) + // The events this session signs, in the order the proposal fixed. Every + // join below is positional against it. + val items = database.frostSigningSessionDao().getItems(sessionId) + if (items.isEmpty()) return - if (ownMessage(database, session, FrostSigningEvents.NONCE) == null) { + val messages = items.map { ByteVector(it.eventId.hexToByteArray()) } + + val ownNonces = ownMessage(database, session, FrostSigningEvents.NONCE) + val ownPartials = ownMessage(database, session, FrostSigningEvents.PARTIAL_SIGNATURE) + + // One nonce per item, regenerated rather than stored -- SecretNonce + // refuses both. Safe because an item's message can never change; see + // the notes on FrostSigningSession. + // + // On demand rather than up front, and that is the point of the `lazy`: + // `advance` runs on every arriving message, so at a batch of k this is + // k native key generations each time, usually to find there is nothing + // left to publish. A guard computed here instead would have to predict + // whether this device turns out to be a signer, which it cannot -- the + // coordinator settles the signer set further down this same pass. + val nonces by lazy { + items.mapIndexed { index, item -> + SecretNonce.generate( + sessionRandom = ByteVector32(item.nonceRandom), + secretShare = secretShare, + publicShare = publicShares?.getOrNull(session.signerId), + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = messages[index], + extraInput = null + ) + } + } + + if (ownNonces == null) { publishOwn( database, localChatRoom, session, FrostSigningEvents.NONCE, - publicNonce.data.toHex() + joinPayload(nonces.map { (_, publicNonce) -> publicNonce.data.toHex() }) ) } - if (session.isCoordinator() && session.aggregatedNonce == null) { - val offered = orderedNonces(database, session) ?: return + if (session.isCoordinator() && session.signerIds == null) { + val offered = orderedNonces(database, session, items.size) ?: return val chosen = offered.take(session.threshold) + val chosenIds = chosen.map { (id, _) -> id } - val aggregated = IndividualNonce.aggregate(chosen.map { it.second }) - .orThrow("aggregating nonces") - - session = update(database, session) { - it.copy( - aggregatedNonce = aggregated.toByteArray().toHex(), - signerIds = chosen.joinToString(",") { (id, _) -> id.toString() } - ) + // One aggregate per item, each built from that item's nonce from + // each chosen signer. Reusing one across two items would be reusing + // R across two messages, which is the whole thing this design is + // arranged to make impossible. + val aggregated = items.indices.map { index -> + IndividualNonce.aggregate(chosen.map { (_, offeredNonces) -> offeredNonces[index] }) + .orThrow("aggregating nonces") + .toByteArray() + .toHex() } + + if (!applyAggregate(database, session, chosenIds, aggregated)) return + session = current(database, session) + broadcast( database = database, localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.SIGNER_SET, - content = aggregated.toByteArray().toHex(), - signerIds = chosen.map { (id, _) -> id } + content = joinPayload(aggregated), + signerIds = chosenIds ) announceStep( database, @@ -580,54 +704,79 @@ object FrostSigningManager { ) } - val aggregatedNonce = session.aggregatedNonce ?: return val signerIds = session.signerIdList() ?: return + // Re-read, because the aggregate above is written to the item rows. + val aggregated = database.frostSigningSessionDao().getItems(sessionId) session = moveTo(database, session, FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES) - val signingSession = Session.create( - aggregatedNonce = AggregatedNonce(aggregatedNonce.hexToByteArray()), - signerIds = signerIds.map { it.toUInt() }, - signerPublicShares = publicShares?.let { shares -> - signerIds.mapNotNull { shares.getOrNull(it) }.takeIf { it.size == signerIds.size } - }, - nParticipants = session.participantCount, - threshold = session.threshold, - tweakCache = tweakCache, - message = message - ) - // A member outside the chosen set has nothing to contribute and is not // holding anybody up. They stay in the session to receive the finished - // signature like everybody else. - if (session.isSigner() && ownMessage(database, session, FrostSigningEvents.PARTIAL_SIGNATURE) == null) { - val partialSignature = signingSession - .sign(secretNonce, secretShare, session.signerId.toUInt()) - .orThrow("signing") + // signatures like everybody else -- and leaving here rather than + // building k FROST sessions to do nothing with is the same saving the + // nonce short-circuit above makes. + val signing = session.isSigner() && ownPartials == null + val aggregating = session.isCoordinator() && !isSigned(database, session) + if (!signing && !aggregating) { + complete(database, session) + return + } + + // One FROST session per item. `Session.create` binds the message and + // the aggregate together, so only the signer set, the shares and the + // tweak cache are shared across a batch. + val signingSessions = aggregated.mapIndexed { index, item -> + Session.create( + aggregatedNonce = AggregatedNonce( + (item.aggregatedNonce ?: return).hexToByteArray() + ), + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = publicShares?.let { shares -> + signerIds.mapNotNull { shares.getOrNull(it) }.takeIf { it.size == signerIds.size } + }, + nParticipants = session.participantCount, + threshold = session.threshold, + tweakCache = tweakCache, + message = messages[index] + ) + } + + if (signing) { + val partialSignatures = signingSessions.mapIndexed { index, signingSession -> + signingSession + .sign(nonces[index].first, secretShare, session.signerId.toUInt()) + .orThrow("signing") + .toHex() + } publishOwn( database, localChatRoom, session, FrostSigningEvents.PARTIAL_SIGNATURE, - partialSignature.toHex() + joinPayload(partialSignatures) ) } - if (session.isCoordinator() && session.signature == null) { - val partials = orderedPartialSignatures(database, session, signerIds) ?: return + if (aggregating) { + val partials = orderedPartialSignatures(database, session, signerIds, aggregated.size) + ?: return - val signature = signingSession - .aggregateSigs(partials.map { ByteVector32(it) }) - .orThrow("aggregating partial signatures") - .toHex() + val signatures = signingSessions.mapIndexed { index, signingSession -> + signingSession + .aggregateSigs(partials.map { ByteVector32(it[index]) }) + .orThrow("aggregating partial signatures") + .toHex() + } + + if (!applySignatures(database, session, signatures)) return + session = current(database, session) - session = update(database, session) { it.copy(signature = signature) } broadcast( database = database, localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.SIGNATURE, - content = signature + content = joinPayload(signatures) ) announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey) } @@ -662,16 +811,25 @@ object FrostSigningManager { * like every other line in the transcript. */ private suspend fun complete(database: MantraDatabase, session: FrostSigningSession) { - val signature = session.signature ?: return + val items = database.frostSigningSessionDao().getItems(session.id) + if (items.isEmpty() || items.any { !it.isSigned() }) return - val signedEvent = signedEvent(session, signature) - val verified = Nip01Crypto.verify( - signature = signature.hexToByteArray(), - hash = session.eventId.hexToByteArray(), - pubKey = signedEvent.pubKey.hexToByteArray() - ) - if (!verified) { - throw IllegalStateException("The aggregated signature does not verify against ${session.eventId}") + // Every signature is checked before any event is applied. A batch is + // all-or-nothing, so a bad one anywhere has to fail the session rather + // than leave some of its events already filed. + val signedEvents = items.map { item -> + val signedEvent = signedEvent(item, item.signature!!) + val verified = Nip01Crypto.verify( + signature = item.signature.hexToByteArray(), + hash = item.eventId.hexToByteArray(), + pubKey = signedEvent.pubKey.hexToByteArray() + ) + if (!verified) { + throw IllegalStateException( + "The aggregated signature does not verify against ${item.eventId}" + ) + } + signedEvent } update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } @@ -683,14 +841,14 @@ object FrostSigningManager { // wire: a signed event authored by the threshold key cannot travel as // an inner event anyway, because the outbound pipeline re-authors // rumors as their sender and would strip the group's signature off. - applySignedEvent(database, session, signedEvent) + signedEvents.forEach { applySignedEvent(database, session, it) } announce( database = database, session = session, messageType = ChatMessage.TYPE_FROST_COMPLETE, - content = "The group signed the event. It took ${session.threshold} of " + - "${session.participantCount} members.", + content = "The group signed ${describe(signedEvents.size)}. It took " + + "${session.threshold} of ${session.participantCount} members.", actor = session.coordinatorPublicKey ) @@ -736,8 +894,8 @@ object FrostSigningManager { * Public so a caller can take the finished event and do whatever it was * signing it for — the session's job ends at a valid signature. */ - fun signedEvent(session: FrostSigningSession, signature: HexKey): Event { - val unsigned = Event.fromJson(session.unsignedEventJson) + fun signedEvent(item: FrostSigningItem, signature: HexKey): Event { + val unsigned = Event.fromJson(item.unsignedEventJson) return Event( id = unsigned.id, @@ -750,9 +908,115 @@ object FrostSigningManager { ) } - /** The finished event, or null while the session is still running. */ - fun signedEvent(session: FrostSigningSession): Event? = - session.signature?.let { signedEvent(session, it) } + /** The finished event, or null while this item is still running. */ + fun signedEvent(item: FrostSigningItem): Event? = + item.signature?.let { signedEvent(item, it) } + + /** Every finished event of a batch, empty while any of them is still running. */ + fun signedEvents(items: List): List = + items.map { it.signature ?: return emptyList() } + .mapIndexed { index, signature -> signedEvent(items[index], signature) } + + /** + * The items a session signs: one per event, each with its own nonce seed. + * + * Independent seeds rather than one derived per index, which would work and + * save nothing worth having. Independence means an off-by-one anywhere in + * the index handling produces a session that fails to aggregate, instead of + * one that signs two messages under a single nonce. + */ + private fun itemsOver(sessionId: String, events: List): List = + events.mapIndexed { index, event -> + FrostSigningItem( + sessionId = sessionId, + itemIndex = index, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = RandomInstance.bytes(32).toHex() + ) + } + + /** + * Records the coordinator's signer set and the aggregated nonce each item is + * signed against, as one write. Returns false when the message does not fit + * the batch, so the caller neither announces nor acts on it. + * + * The two belong together. [FrostSigningSession.signerIds] is what the rest + * of the session gates on, so a session holding it while an item still had no + * aggregate would stall on a message that has already been delivered — and + * the write that cleared the stall would be a second aggregate for an item + * that had one, which is the case the write-once rule exists to prevent. + * Items first, in one transaction, then the session: the gate is only ever + * set on a batch that is entirely ready. + */ + private suspend fun applyAggregate( + database: MantraDatabase, + session: FrostSigningSession, + signerIds: List, + aggregatedNonces: List + ): Boolean { + val items = database.frostSigningSessionDao().getItems(session.id) + if (items.isEmpty() || items.size != aggregatedNonces.size) { + logger.w( + "Session ${session.id}: signer set carries ${aggregatedNonces.size} " + + "aggregated nonce(s) for ${items.size} item(s); ignoring" + ) + return false + } + + database.frostSigningSessionDao().upsertItems( + items.mapIndexed { index, item -> item.copy(aggregatedNonce = aggregatedNonces[index]) } + ) + update(database, session) { it.copy(signerIds = signerIds.joinToString(",")) } + + return true + } + + /** + * Records the group's finished signatures, all of them or none. Returns false + * when the message does not fit the batch. + * + * Settled as a unit for the same reason the aggregate is: a batch holding + * some of its signatures is one the group can neither finish nor safely + * retry, since a retry means fresh nonces for events that already have a + * signature against the old ones. + */ + private suspend fun applySignatures( + database: MantraDatabase, + session: FrostSigningSession, + signatures: List + ): Boolean { + val items = database.frostSigningSessionDao().getItems(session.id) + if (items.isEmpty() || items.size != signatures.size) { + logger.w( + "Session ${session.id}: ${signatures.size} signature(s) for " + + "${items.size} item(s); ignoring" + ) + return false + } + + database.frostSigningSessionDao().upsertItems( + items.mapIndexed { index, item -> item.copy(signature = signatures[index]) } + ) + + return true + } + + /** + * Whether the group has produced every signature this session was opened for. + * + * Counted rather than flagged, so it cannot disagree with the rows it + * describes. A session with no items is not signed — it is one whose proposal + * has not landed yet. + */ + private suspend fun isSigned(database: MantraDatabase, session: FrostSigningSession): Boolean { + val total = database.frostSigningSessionDao().countItems(session.id) + + return total > 0 && database.frostSigningSessionDao().countSignedItems(session.id) == total + } + + /** "the event" or "3 events", for a transcript line that reads the same at either size. */ + private fun describe(count: Int): String = if (count == 1) "the event" else "$count events" /** * The nonces on offer, as (signer id, nonce), ordered by signer id — or null @@ -765,17 +1029,25 @@ object FrostSigningManager { */ private suspend fun orderedNonces( database: MantraDatabase, - session: FrostSigningSession - ): List>? { + session: FrostSigningSession, + items: Int + ): List>>? { val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null val idByMember = signerIds(database, key) val offered = database.frostSigningSessionDao() .getMessagesByKind(session.id, FrostSigningEvents.NONCE) .mapNotNull { message -> - idByMember[message.signerPublicKey]?.let { id -> - id to IndividualNonce(message.payload.hexToByteArray()) + val id = idByMember[message.signerPublicKey] ?: return@mapNotNull null + val values = splitPayload(message.payload, items) ?: run { + logger.w( + "Session ${session.id}: ${message.signerPublicKey.take(8)} offered a " + + "nonce payload that is not $items value(s); leaving them out" + ) + return@mapNotNull null } + + id to values.map { IndividualNonce(it.hexToByteArray()) } } .sortedBy { (id, _) -> id } @@ -795,8 +1067,9 @@ object FrostSigningManager { private suspend fun orderedPartialSignatures( database: MantraDatabase, session: FrostSigningSession, - signerIds: List - ): List? { + signerIds: List, + items: Int + ): List>? { val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null val memberById = signerIds(database, key).entries.associate { (member, id) -> id to member } @@ -805,7 +1078,9 @@ object FrostSigningManager { .associate { it.signerPublicKey to it.payload } val partials = signerIds.mapNotNull { id -> - memberById[id]?.let { payloadByMember[it] } + val payload = memberById[id]?.let { payloadByMember[it] } ?: return@mapNotNull null + + splitPayload(payload, items)?.map { it.hexToByteArray() } } if (partials.size < signerIds.size) { @@ -813,7 +1088,50 @@ object FrostSigningManager { return null } - return partials.map { it.hexToByteArray() } + return partials + } + + /** + * A signer's whole contribution to a batch, as one payload. + * + * Comma separated, the encoding [FrostSigningSession.signerIds] already uses, + * and at a batch of one it is the bare value — which is what keeps a + * single-event session on exactly the wire it has always been on. + * + * One row per signer per kind rather than one per item, deliberately. + * [FrostSignerMessage]'s key is what makes a redelivered message replace its + * predecessor instead of adding a row, and splitting by item would multiply + * the ways a partial delivery can look like a complete one. + */ + private fun joinPayload(values: List): String = values.joinToString(",") + + /** + * The other half, and strict: a payload not carrying exactly [expected] + * values is refused rather than truncated or padded. + * + * Checked here rather than in [record] on purpose. Payloads are stored + * without being parsed, which is what lets a nonce arrive before the proposal + * that would give it a length to be checked against. This runs where the + * session — and so the length — is known. + */ + private fun splitPayload(payload: String, expected: Int): List? = + payload.split(",").map { it.trim() }.takeIf { it.size == expected } + + /** [splitPayload] against the number of events this session signs. */ + private suspend fun splitForSession( + database: MantraDatabase, + session: FrostSigningSession, + payload: String + ): List? { + val expected = database.frostSigningSessionDao().countItems(session.id) + + return splitPayload(payload, expected) ?: run { + logger.w( + "Session ${session.id}: payload carries ${payload.split(",").size} value(s) " + + "for $expected item(s); ignoring" + ) + null + } } /** @@ -1091,7 +1409,10 @@ object FrostSigningManager { * it. The two must agree, or the screen offers an approval that does nothing, * or none while the session sits still. */ - fun isAwaitingApproval(session: FrostSigningSession): Boolean { + fun isAwaitingApproval( + session: FrostSigningSession, + items: List + ): Boolean { if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) { return false } @@ -1100,7 +1421,7 @@ object FrostSigningManager { // the session on its next pass without asking them anything. Offering the // decision anyway would be offering two bad answers: a nonce nobody is // waiting for, or a refusal that abandons a signature already made. - if (session.signature != null) return false + if (items.isNotEmpty() && items.all { it.isSigned() }) return false return session.signApprovedAt == null } @@ -1118,8 +1439,9 @@ object FrostSigningManager { sessionId: String ) { val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + val items = database.frostSigningSessionDao().getItems(sessionId) - if (!isAwaitingApproval(session)) { + if (!isAwaitingApproval(session, items)) { logger.i("Session $sessionId is not waiting on an approval; ignoring it") return } @@ -1145,8 +1467,9 @@ object FrostSigningManager { sessionId: String ) { val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + val items = database.frostSigningSessionDao().getItems(sessionId) - if (!isAwaitingApproval(session)) return + if (!isAwaitingApproval(session, items)) return fail( database = database, @@ -1163,15 +1486,20 @@ object FrostSigningManager { ) } - private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) = + private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) { + val count = database.frostSigningSessionDao().countItems(session.id) + announce( database = database, session = session, messageType = ChatMessage.TYPE_FROST_STARTED, - content = "asked the group to sign something with its shared key. It takes " + - "${session.threshold} of ${session.participantCount} members to do it.", + content = "asked the group to sign " + + (if (count > 1) "$count events" else "something") + + " with its shared key. It takes ${session.threshold} of " + + "${session.participantCount} members to do it.", actor = session.coordinatorPublicKey ) + } /** * Tells the group's chat that this session is waiting on the reader. @@ -1188,12 +1516,15 @@ object FrostSigningManager { update(database, session) { it.copy(approvalRequestedAt = Clock.System.now()) } + val count = database.frostSigningSessionDao().countItems(session.id) + announce( database = database, session = session, messageType = ChatMessage.TYPE_FROST_APPROVAL_NEEDED, - content = "Your approval is needed to sign with the group's shared key. Nothing " + - "has been published from this device yet.", + content = "Your approval is needed to sign " + + (if (count > 1) "$count events " else "") + + "with the group's shared key. Nothing has been published from this device yet.", actor = session.userPublicKey ) } @@ -1212,19 +1543,29 @@ object FrostSigningManager { kind: Kind, actor: HexKey ) { + // A batch is still one line per member per step -- what changes is the + // number in it. Every one of these lines describes work that covered the + // whole batch, so saying so is the difference between a member reading + // "signed their part" and knowing what they signed. + val count = database.frostSigningSessionDao().countItems(session.id) + val batch = count > 1 + val (messageType, content) = when (kind) { FrostSigningEvents.NONCE -> ChatMessage.TYPE_FROST_NONCE to - "offered to help sign, sending the one-time value their signature needs." + "offered to help sign, sending the one-time " + + (if (batch) "values their signatures need." else "value their signature needs.") FrostSigningEvents.SIGNER_SET -> ChatMessage.TYPE_FROST_SIGNER_SET to "chose who is signing and combined their one-time values." FrostSigningEvents.PARTIAL_SIGNATURE -> ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE to - "signed their part. On its own it proves nothing; combined with the " + - "others it is the group's signature." + "signed their part" + (if (batch) " of all $count events" else "") + + ". On its own it proves nothing; combined with the others it is " + + "the group's " + (if (batch) "signatures." else "signature.") FrostSigningEvents.SIGNATURE -> ChatMessage.TYPE_FROST_SIGNATURE to - "combined the parts into the group's signature." + "combined the parts into the group's " + + (if (batch) "$count signatures." else "signature.") // PROPOSAL and FAILURE are announced by the code that acts on them -- // both say more than the message itself carries. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index 750197b1..e1e094d9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -72,6 +72,12 @@ object GroupKeyStateManager { * [key] is handed to the signing session rather than looked up from the * room, because the room has no key state yet and looking one up is exactly * what this session exists to make possible. + * + * A session of one, always, and never batched with anything else. A batch is + * all-or-nothing, so it is only as available as its worst item -- and this is + * the statement every other session in the room is opened against. Bundling + * it with a dialect would make the room's ability to sign at all depend on + * that dialect's aggregation succeeding. */ suspend fun propose( database: MantraDatabase, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt index 04b99d1d..c26c79f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -1,6 +1,9 @@ package press.mantra.compose.nostr.frost +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.serialization.json.jsonArray +import press.mantra.compose.network.serialization.CommonJson import press.mantra.compose.nostr.frost.tags.FrostKeyTag import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag @@ -47,8 +50,9 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag */ object FrostSigningEvents { /** - * Opens a session. Content is the unsigned nostr event, as JSON; the key to - * sign with is named in [FrostKeyTag]. + * Opens a session. Content is the unsigned nostr event -- or, for a batch, + * the array of them -- as JSON; the key to sign with is named in [FrostKeyTag]. + * See [encodeProposal] for why the two forms are not one. */ val PROPOSAL: Kind = 30320 @@ -104,4 +108,45 @@ object FrostSigningEvents { fun parseSignerIds(tags: Array>): List? = tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds + + /** + * The unsigned events of a proposal, as its content. + * + * A batch of one serialises as the bare event object it always did, and only + * a genuine batch becomes an array. That is not tidiness. A build predating + * batching reads an array with `Event.fromJsonOrNull`, gets null, and drops + * the proposal -- so an old device refuses a batch outright rather than + * signing part of one, while single signing keeps working right through a + * mixed-version rollout. Emitting an array unconditionally would break every + * one-event session for those devices and buy nothing. + * + * An empty list has no honest encoding and is not one, so callers do not + * produce it and [decodeProposal] does not accept it. + */ + fun encodeProposal(events: List): String = + events.singleOrNull()?.toJson() + ?: events.joinToString(separator = ",", prefix = "[", postfix = "]") { it.toJson() } + + /** + * The other half. Both forms are accepted, permanently: proposals in the old + * shape do not stop arriving just because this build stopped writing them. + * + * All-or-nothing. An array with one unreadable element is refused rather than + * silently shortened, because the batch's length is what every later payload + * is checked against -- a proposal that quietly lost an event would have every + * signer's contribution rejected for being the wrong size, which is a stall + * with no message to blame. + */ + fun decodeProposal(content: String): List? { + if (!content.trimStart().startsWith("[")) { + return Event.fromJsonOrNull(content)?.let { listOf(it) } + } + + val elements = runCatching { CommonJson.parseToJsonElement(content).jsonArray } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + ?: return null + + return elements.map { element -> Event.fromJsonOrNull(element.toString()) ?: return null } + } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt index 44d4a534..82ce1e3c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -3,9 +3,11 @@ 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 com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -24,6 +26,17 @@ interface FrostSigningRepository { fun observeMessages(sessionId: String): Flow> + /** + * The events a session signs, in the order its proposal fixed. + * + * Separate from the session because a session signs a batch, and what + * separates one event of it from another is forced by FROST rather than + * chosen — see [FrostSigningItem]. + */ + fun observeItems(sessionId: String): Flow> + + suspend fun getItems(sessionId: String): List + suspend fun getSessionById(sessionId: String): FrostSigningSession? /** @@ -52,14 +65,41 @@ interface FrostSigningRepository { content: String ): FrostSigningSession? + /** + * Opens one session over several events, so a group answers once instead of + * once per event. + * + * Four group events and one approval whatever the size, but k independent + * FROST instances underneath -- there is no such thing as one signature over + * k messages, and no way to share a nonce between two of them. See + * [FrostSigningItem]. + * + * Two things a caller has to decide before reaching for this. + * + * **A batch is only as available as its worst item.** It is all-or-nothing: + * if any event cannot be aggregated the session fails and none of them are + * applied. Events that do not belong together should not travel together. + * + * **A retry is a new batch, never this one again.** Its items' nonce seeds + * have already been published against an aggregate, and reusing one would + * produce two partial signatures over a single secret nonce -- which is how + * a share is extracted. Propose afresh; the manager mints new seeds by + * construction. + */ + suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List> + ): 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? + /** The finished events, or empty while any of them is still running. */ + fun signedEvents(items: List): List companion object { val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository { @@ -71,6 +111,11 @@ interface FrostSigningRepository { override fun observeMessages(sessionId: String): Flow> = flowOf(emptyList()) + override fun observeItems(sessionId: String): Flow> = + flowOf(emptyList()) + + override suspend fun getItems(sessionId: String): List = emptyList() + override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null @@ -85,11 +130,17 @@ interface FrostSigningRepository { content: String ): FrostSigningSession? = null + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List> + ): 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 + override fun signedEvents(items: List): List = emptyList() } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index a3a37705..dbd6694e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -139,15 +139,25 @@ fun FrostSigningScreen( // flash an error on the way in. val session = state.session ?: return@Scaffold Loading(padding) + val proposed = frostSigningViewModel.proposedEvents(state.items) + + // Every event of the batch has to be readable before any of it can + // be signed. A member cannot check what they cannot see, and a + // batch is all-or-nothing: agreeing to the two that rendered would + // be agreeing to the third as well. + val readable = state.items.isNotEmpty() && proposed.size == state.items.size + + val scrollState = rememberScrollState() + Column( modifier = Modifier .padding(padding) .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp) ) { - WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session)) + WhatIsBeingSigned(proposed, state.items.size) HorizontalDivider() @@ -230,22 +240,43 @@ fun FrostSigningScreen( // Asked rather than re-derived: the manager owns when a session // is still waiting on its owner, and a second copy of that rule // here is a second copy to keep in step. - if (FrostSigningManager.isAwaitingApproval(session)) { + if (FrostSigningManager.isAwaitingApproval(session, state.items)) { HorizontalDivider() Text( text = "Nothing has been published from this device yet. Signing " + - "puts your share behind this event; it cannot be taken back.", + "puts your share behind " + + (if (proposed.size > 1) "all ${proposed.size} of these events" else "this event") + + "; it cannot be taken back.", style = MaterialTheme.typography.bodySmall ) + // A batch can hide an event below the fold in a way one + // event cannot: what is off-screen is not further detail + // about the thing on screen, it is a different thing the + // member would also be signing. So a batch's Sign button + // waits until the list has been read to the end. maxValue + // is Int.MAX_VALUE until the first layout, and 0 when + // everything already fits. + val seenEverything = proposed.size <= 1 || + (scrollState.maxValue != Int.MAX_VALUE && scrollState.value >= scrollState.maxValue) + + if (readable && !seenEverything) { + Text( + text = "Read to the end of the list to sign.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically ) { Button( - enabled = !frostSigningViewModel.isActionPending.value, + enabled = readable && seenEverything && + !frostSigningViewModel.isActionPending.value, onClick = { frostSigningViewModel.approve(onNavigateBack) } ) { Icon(Icons.Default.Draw, contentDescription = null) @@ -253,6 +284,11 @@ fun FrostSigningScreen( Text("Sign") } + // Declining stays available whatever the screen could + // not show. A member who cannot check what they are + // being asked to sign should still be able to say no, + // and saying nothing is indistinguishable from a phone + // in a pocket -- which leaves the group waiting. TextButton( colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error @@ -290,16 +326,48 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { * refusing to describe an event is better than describing it wrongly. */ @Composable -private fun WhatIsBeingSigned(event: Event?) { - if (event == null) { +private fun WhatIsBeingSigned(events: List, expected: Int) { + if (expected == 0 || events.size != expected) { Text( - text = "This session's event could not be read, so there is nothing to check " + - "before signing. Don't sign it.", + text = if (expected <= 1) { + "This session's event could not be read, so there is nothing to check " + + "before signing. Don't sign it." + } else { + "Only ${events.size} of this session's $expected events could be read, so " + + "there is no way to check what you would be signing. Don't sign it." + }, color = MaterialTheme.colorScheme.error ) return } + Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { + if (events.size > 1) { + Text( + text = "${events.size} events, signed together", + style = MaterialTheme.typography.labelMedium + ) + } + + events.forEachIndexed { index, event -> + if (index > 0) HorizontalDivider() + + OneThingBeingSigned(event) + } + + Text( + text = "Signed by the group, not by you. Once enough members sign, " + + (if (events.size > 1) "these are" else "this is") + + " published under the group's shared key.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** One event of the batch, described as the thing it is. */ +@Composable +private fun OneThingBeingSigned(event: Event) { val (label, detail) = when (event.kind) { DialectEvent.KIND -> "New dialect" to DialectEvent( event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig @@ -351,13 +419,6 @@ private fun WhatIsBeingSigned(event: Event?) { 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 - ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 27131b7b..3d840986 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -70,6 +71,7 @@ fun SelectChatRoomTypeScreen( activeWalletStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, + dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit, ) { val selectChatRoomTypeViewModel: SelectChatRoomTypeViewModel = viewModel( @@ -81,7 +83,8 @@ fun SelectChatRoomTypeScreen( initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, activeWalletStateFlow = activeWalletStateFlow, nostrRepository = nostrRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + dkgRepository = dkgRepository ) ) @@ -104,6 +107,7 @@ fun SelectChatRoomTypeScreen( val isActionPending = selectChatRoomTypeViewModel.isActionPending.value val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded + val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value val adminCount = selectChatRoomTypeViewModel.adminCount val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST @@ -188,6 +192,22 @@ fun SelectChatRoomTypeScreen( } } + if (keyCeremonyNotStarted) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text( + modifier = Modifier.padding(15.dp), + text = "$name was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.", + style = MaterialTheme.typography.bodyMedium + ) + } + } + Text( text = "This decides who can change the group later. You can't switch afterwards.", style = MaterialTheme.typography.labelMedium, @@ -216,7 +236,11 @@ fun SelectChatRoomTypeScreen( } else { "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect." }, - footnote = "No single admin can change the group alone, and the group outlives any one of you.", + // Says what tapping create actually does, because it is the one + // thing here that asks something of everybody else: the group's + // first act is a ceremony each member has to approve their way + // through before there is a key. + footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.", isSelected = isRobustSelected, isEnabled = isRobustAvailable, disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, @@ -447,6 +471,7 @@ private fun SelectChatRoomTypeScreenPreview() { activeWalletStateFlow = MutableStateFlow(null), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, onNavigateToRoute = {} ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index c6c3cdf3..7282a01c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -543,6 +543,7 @@ fun MantraNavHost( activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, onNavigateToRoute = { chatRoomResultRoute -> navController.navigate( chatRoomResultRoute diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt index b2942491..6a1d0afa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.repository.ChatRepository @@ -66,12 +67,14 @@ class FrostSigningViewModel( combine( frostSigningRepository.observeSessionById(id), - frostSigningRepository.observeMessages(id) - ) { session, messages -> session to messages } - .collect { (session, messages) -> + frostSigningRepository.observeMessages(id), + frostSigningRepository.observeItems(id) + ) { session, messages, items -> Triple(session, messages, items) } + .collect { (session, messages, items) -> frostSigningUIState = FrostSigningUIState.Loaded( localChatRoom = localChatRoom, session = session, + items = items, offeredNonce = messages .filter { it.kind == FrostSigningEvents.NONCE } .map { it.signerPublicKey } @@ -85,12 +88,12 @@ class FrostSigningViewModel( } } - /** The event the group is being asked to sign, for showing it before they agree. */ - fun proposedEvent(session: FrostSigningSession): Event? = - Event.fromJsonOrNull(session.unsignedEventJson) + /** The events the group is being asked to sign, for showing them before they agree. */ + fun proposedEvents(items: List): List = + items.mapNotNull { Event.fromJsonOrNull(it.unsignedEventJson) } - fun signedEvent(session: FrostSigningSession): Event? = - frostSigningRepository.signedEvent(session) + fun signedEvents(items: List): List = + frostSigningRepository.signedEvents(items) fun approve(onDone: () -> Unit) { val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt index 038cd51f..fccb41f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -15,6 +15,7 @@ import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute @@ -59,6 +60,7 @@ class SelectChatRoomTypeViewModel( val activeWalletStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, + val dkgRepository: DkgRepository, ): ViewModel() { var selectChatRoomTypeUIState: SelectChatRoomTypeUIState by mutableStateOf(initialSelectChatRoomTypeUIState) @@ -81,6 +83,15 @@ class SelectChatRoomTypeViewModel( /** Members the group was created without, because no key package ever showed up. */ val membersNotAdded = mutableStateListOf() + /** + * Set when a [ChatRoomType.ROBUST] room was made but its key ceremony was not. + * + * The room is real and usable either way -- only the shared key is missing, and + * it can be started again from the group's details. Worth saying rather than + * navigating on, because the key is the whole of what robust means. + */ + val keyCeremonyNotStarted: MutableState = mutableStateOf(false) + /** * Everyone who administers the group under [ChatRoomType.ROBUST]: the picked * members plus the creator. @@ -188,13 +199,16 @@ class SelectChatRoomTypeViewModel( isActionPending.value = true + val nostrPrivateKeyBytes = nostrPrivateKey.value.toByteArray() val keyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() + privKey = nostrPrivateKeyBytes ) when (selectedChatRoomType.value) { ChatRoomType.CONVENIENT -> createMarmotChatRoom(keyPair, onNavigateToRoute) - ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, onNavigateToRoute) + // The ceremony needs the secret itself, not the pair: the ChillDKG host + // key is derived from it rather than being it. + ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, nostrPrivateKeyBytes, onNavigateToRoute) } } @@ -287,13 +301,23 @@ class SelectChatRoomTypeViewModel( * invite anyone to and no key package to wait on — everybody picked is in the room * the moment it exists, and learns about it from the first message. * - * TODO: the quorum the user picked has nowhere to live here. NIP-17 has no group - * state to change and so nothing to approve — the member set is whatever a message - * is addressed to, and a different set is simply a different room. Enforcing t-of-n - * needs a governance layer this protocol does not have. + * That first message is the key ceremony, opened here rather than left for somebody + * to find a button for. + * + * NIP-17 itself has nothing for the quorum to govern — no group state to change, and + * a different member set is simply a different room — so the only thing that can + * carry it is a key the group generates together and can only sign with t of n of + * them present. Everything that ceremony needs is settled by the time the room + * exists: who is in it, and how many of them have to agree. Waiting would mean + * handing the user the room they asked for minus the thing that makes it robust. + * + * Opening it is also how the rest of the group hears of the room at all. Standing up + * a NIP-17 room sends nothing to anybody; the proposal is the first event out, and + * the inbound path builds the same room on the other side from its p-tags. */ private fun createNip17ChatRoom( keyPair: KeyPair, + nostrPrivateKey: ByteArray, onNavigateToRoute: (Route) -> Unit ) { viewModelScope.launch(Dispatchers.IO) { @@ -304,18 +328,43 @@ class SelectChatRoomTypeViewModel( description = description ) - withContext(Dispatchers.Main) { - isActionPending.value = false - - if (localChatRoom == null) { + if (localChatRoom == null) { + withContext(Dispatchers.Main) { + isActionPending.value = false onNavigateToRoute.invoke( ImplementationPendingRoute("Something went wrong") ) + } + return@launch + } + + createdChatRoomId.value = localChatRoom.chatRoom.id + + // The room's membership is the set this screen was opened for, so the + // quorum picked against [adminCount] is the same t-of-n the ceremony is + // asked for. A room that already has a ceremony -- the id is derived from + // its members, so making the same group twice returns the same room -- + // hands that one back instead of opening a second. + val session = dkgRepository.proposeRitual( + localChatRoom = localChatRoom, + userPublicKey = keyPair.pubKey.toHexKey(), + nostrPrivateKey = nostrPrivateKey, + threshold = quorum.value + ) + + withContext(Dispatchers.Main) { + isActionPending.value = false + + if (session == null) { + // Nothing is rolled back: the room works, the group can talk in it, + // and the ceremony can be opened again from the group's details. + // Said here rather than navigated past, because a robust group + // without a shared key is not what the user asked for. + logger.e("Created ${localChatRoom.chatRoom.id} without a key ceremony") + keyCeremonyNotStarted.value = true return@withContext } - createdChatRoomId.value = localChatRoom.chatRoom.id - onNavigateToRoute.invoke( ChatRoomMessagingRoute( activeUserPublicKey = keyPair.pubKey.toHexKey(), @@ -393,7 +442,8 @@ class SelectChatRoomTypeViewModel( initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, activeWalletStateFlow: StateFlow, nostrRepository: NostrRepository, - chatRepository: ChatRepository + chatRepository: ChatRepository, + dkgRepository: DkgRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { SelectChatRoomTypeViewModel( @@ -404,7 +454,8 @@ class SelectChatRoomTypeViewModel( initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, activeWalletStateFlow = activeWalletStateFlow, nostrRepository = nostrRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + dkgRepository = dkgRepository ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt index ccf8cdcb..515a603f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt @@ -1,6 +1,7 @@ package press.mantra.compose.ui.view.state import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -11,6 +12,12 @@ sealed interface FrostSigningUIState { /** Null on the first emission, before the session has been collected. */ val session: FrostSigningSession? = null, + /** + * The events the session signs, in the order its proposal fixed. Empty + * on the first emission, and for a session whose proposal has not landed. + */ + val items: List = emptyList(), + /** Who has offered a nonce, so the screen can name who it is waiting on. */ val offeredNonce: Set = emptySet(), diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt index 97460090..5a34e8aa 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -20,6 +20,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Instant import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.types.FrostSigningStage import press.mantra.compose.extensions.toHex @@ -159,6 +160,105 @@ class FrostSigningRoundTest { ) } + @Test + fun `one signer set signs a batch of three, and every signature verifies`() { + // The manager's batch, with the database taken out of the way: one signer + // set and one tweak cache shared, and a nonce, a Session and a signature + // per event. If any of that is wired up wrongly the aggregate simply + // fails to verify, which is the whole reason this file exists. + val ids = listOf("first", "second", "third").map(::eventId) + val messages = ids.map { ByteVector(it.hexToByteArray()) } + + val signerIds = listOf(0, 1) + + // A seed per signer per item. The manager mints these independently; here + // they only have to differ, which is the property under test. + val nonces = signerIds.map { signerId -> + messages.mapIndexed { index, message -> + nonceOf(signerId, message, "f".repeat(62) + "$index${signerId + 1}") + } + } + + val signatures = messages.mapIndexed { index, message -> + val session = sessionFor(signerIds, nonces.map { it[index].second }, message) + + val partials = signerIds.mapIndexed { position, signerId -> + session.sign( + nonces[position][index].first, + keyMaterial.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + session.aggregateSigs(partials).right!! + } + + ids.forEachIndexed { index, id -> + assertTrue( + Nip01Crypto.verify( + signature = signatures[index].toByteArray(), + hash = id.hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ), + "item $index of the batch must verify against the room's own key" + ) + } + } + + @Test + fun `a batch signature does not carry to another item of the same batch`() { + // What keeps a batch k independent signatures rather than one loose one. + // Signing three events in lockstep must not make any of them + // interchangeable. + val ids = listOf("first", "second").map(::eventId) + val messages = ids.map { ByteVector(it.hexToByteArray()) } + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + messages.mapIndexed { index, message -> + nonceOf(signerId, message, "9".repeat(62) + "$index${signerId + 1}") + } + } + + val session = sessionFor(signerIds, nonces.map { it[0].second }, messages[0]) + val partials = signerIds.mapIndexed { position, signerId -> + session.sign( + nonces[position][0].first, + keyMaterial.secretShares[signerId], + signerId.toUInt() + ).right!! + } + val first = session.aggregateSigs(partials).right!! + + assertFalse( + Nip01Crypto.verify( + signature = first.toByteArray(), + hash = ids[1].hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ), + "item 0's signature must not verify against item 1" + ) + } + + @Test + fun `two items of a batch never share a nonce`() { + // The one mistake in this whole design that loses the key, asserted at the + // level where it would be made. `SecretNonce.generate` mixes the message + // in, so two items of a batch cannot collide even given the same seed -- + // but the manager gives them distinct seeds as well, and both halves are + // checked here because either alone is enough to be relied on by accident. + val messages = listOf("first", "second").map { ByteVector(eventId(it).hexToByteArray()) } + + val sameSeed = messages.map { nonceOf(0, it, "7".repeat(64)).second.data.toHex() } + assertEquals(2, sameSeed.toSet().size, "one seed under two messages must give two nonces") + + val distinctSeeds = messages.mapIndexed { index, message -> + nonceOf(0, message, "8".repeat(63) + "$index").second.data.toHex() + } + assertEquals(2, distinctSeeds.toSet().size) + assertEquals(emptySet(), sameSeed.toSet().intersect(distinctSeeds.toSet())) + } + @Test fun `a signature over one event does not verify against another`() { val id = eventId("the group agrees") @@ -227,20 +327,30 @@ class FrostSigningSessionTest { threshold = 2, participantCount = 3, signerId = signerId, - unsignedEventJson = "{}", - eventId = "e".repeat(64), - nonceRandom = "f".repeat(64), signerIds = signerIds ) + /** The one event such a session signs, signed or not. */ + private fun items(signature: String? = null) = listOf( + FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, + unsignedEventJson = "{}", + eventId = "e".repeat(64), + nonceRandom = "f".repeat(64), + signature = signature + ) + ) + @Test fun `a session waits on its owner until they answer`() { val open = session(signerId = 2, signerIds = null) - assertTrue(FrostSigningManager.isAwaitingApproval(open)) + assertTrue(FrostSigningManager.isAwaitingApproval(open, items())) assertFalse( FrostSigningManager.isAwaitingApproval( - open.copy(signApprovedAt = Instant.fromEpochSeconds(1)) + open.copy(signApprovedAt = Instant.fromEpochSeconds(1)), + items() ) ) } @@ -249,8 +359,12 @@ class FrostSigningSessionTest { fun `a session that has settled asks its owner nothing`() { val open = session(signerId = 2, signerIds = null) - assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE))) - assertFalse(FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED))) + assertFalse( + FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.COMPLETE), items()) + ) + assertFalse( + FrostSigningManager.isAwaitingApproval(open.copy(stage = FrostSigningStage.FAILED), items()) + ) } @Test @@ -261,9 +375,10 @@ class FrostSigningSessionTest { // decision in that window offers two bad answers: a nonce nobody is // waiting for, or a refusal that abandons a signature that exists. val signedWithoutThem = session(signerId = 2, signerIds = "0,1") - .copy(signature = "a".repeat(128)) - assertFalse(FrostSigningManager.isAwaitingApproval(signedWithoutThem)) + assertFalse( + FrostSigningManager.isAwaitingApproval(signedWithoutThem, items("a".repeat(128))) + ) } @Test @@ -415,7 +530,7 @@ class FrostSigningCompletionTest { * never named them. Every column the completion path reads is here; the ones * it must not need are deliberately left null. */ - private fun leftOutMemberSession(signature: String? = null) = FrostSigningSession( + private fun leftOutMemberSession() = FrostSigningSession( id = "s".repeat(64), chatRoomId = "room", coordinatorPublicKey = "c".repeat(64), @@ -425,24 +540,30 @@ class FrostSigningCompletionTest { participantCount = 3, signerId = 2, derivationPath = SharedKeyDerivation.formatPath(), + signerIds = null, + signApprovedAt = null + ) + + /** The event that session signs: everything completing it needs, and nothing more. */ + private fun leftOutMemberItem(signature: String? = null) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, unsignedEventJson = unsignedEvent.toJson(), eventId = unsignedEvent.id, nonceRandom = "f".repeat(64), aggregatedNonce = null, - signerIds = null, - signature = signature, - signApprovedAt = null + signature = signature ) @Test fun `a member who never took part can still check what the group signed`() { - val session = leftOutMemberSession(signature) - val signed = FrostSigningManager.signedEvent(session)!! + val item = leftOutMemberItem(signature) + val signed = FrostSigningManager.signedEvent(item)!! assertTrue( Nip01Crypto.verify( signature = signed.sig.hexToByteArray(), - hash = session.eventId.hexToByteArray(), + hash = item.eventId.hexToByteArray(), pubKey = signed.pubKey.hexToByteArray() ), "completing must need only the row: the event, its id and the signature" @@ -454,7 +575,7 @@ class FrostSigningCompletionTest { // Not rebuilt and not rehashed: the id a session is pinned to is the id // the signature is over, so anything that changed here would produce an // event whose signature verifies against nothing. - val signed = FrostSigningManager.signedEvent(leftOutMemberSession(signature))!! + val signed = FrostSigningManager.signedEvent(leftOutMemberItem(signature))!! assertEquals(unsignedEvent.id, signed.id) assertEquals(unsignedEvent.pubKey, signed.pubKey) @@ -466,15 +587,22 @@ class FrostSigningCompletionTest { @Test fun `there is no finished event until the signature arrives`() { - assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberSession())) + assertEquals(null, FrostSigningManager.signedEvent(leftOutMemberItem())) } @Test fun `the arrived signature is what stops the session asking`() { // The pair that matters to the screen and the transcript: the same row, // before and after the group finished without this member. - assertTrue(FrostSigningManager.isAwaitingApproval(leftOutMemberSession())) - assertFalse(FrostSigningManager.isAwaitingApproval(leftOutMemberSession(signature))) + assertTrue( + FrostSigningManager.isAwaitingApproval(leftOutMemberSession(), listOf(leftOutMemberItem())) + ) + assertFalse( + FrostSigningManager.isAwaitingApproval( + leftOutMemberSession(), + listOf(leftOutMemberItem(signature)) + ) + ) } @Test @@ -482,7 +610,7 @@ class FrostSigningCompletionTest { // What the check is for. A coordinator passing off something else must not // get it applied and announced as the group's, and the row is all there is // to catch it with. - val other = leftOutMemberSession(signature).copy( + val other = leftOutMemberItem(signature).copy( eventId = EventHasher.hashId( pubKey = groupPubKey, createdAt = 1_700_000_000L, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index ef1e3f52..e8340226 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -19,7 +19,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Instant -import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.frost.GroupKeyStateEvent @@ -281,21 +281,14 @@ class GroupKeyStateTest { val unsigned = unsignedKeyState(content, tags, createdAt, author) return FrostSigningManager.signedEvent( - session = sessionOver(unsigned), + item = itemOver(unsigned), signature = groupSignature(signer, unsigned.id) ) } - private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( - id = "s".repeat(64), - chatRoomId = chatRoomId, - coordinatorPublicKey = "c00rd1na70r", - userPublicKey = "c00rd1na70r", - dkgSessionId = "ceremony-1", - threshold = threshold, - participantCount = participants, - signerId = 0, - derivationPath = SharedKeyDerivation.formatPath(path), + private fun itemOver(unsignedEvent: Event) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, unsignedEventJson = unsignedEvent.toJson(), eventId = unsignedEvent.id, nonceRandom = "f".repeat(64) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt index a88d7dc9..1e78e49c 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SharedKeyDerivationTest.kt @@ -170,9 +170,6 @@ class SharedKeyDerivationTest { threshold = 2, participantCount = 3, signerId = 0, - derivationPath = derivationPath, - unsignedEventJson = "{}", - eventId = "e".repeat(64), - nonceRandom = "f".repeat(64) + derivationPath = derivationPath ) } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt index 26bc90f4..6aeea827 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedArtifactTest.kt @@ -18,7 +18,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.FrostSigningItem import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.extensions.toHex @@ -100,24 +100,17 @@ class SignedArtifactTest { sig = "" ) - private fun sessionOver(unsignedEvent: Event) = FrostSigningSession( - id = "s".repeat(64), - chatRoomId = chatRoomId, - coordinatorPublicKey = proposer, - userPublicKey = proposer, - dkgSessionId = "k".repeat(64), - threshold = threshold, - participantCount = participants, - signerId = 0, - derivationPath = SharedKeyDerivation.formatPath(), + private fun itemOver(unsignedEvent: Event) = FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = 0, unsignedEventJson = unsignedEvent.toJson(), eventId = unsignedEvent.id, nonceRandom = "f".repeat(64) ) - /** A quorum signing the session's event, in the manager's order. */ - private fun groupSignature(session: FrostSigningSession): String { - val message = ByteVector(session.eventId.hexToByteArray()) + /** A quorum signing the item's event, in the manager's order. */ + private fun groupSignature(item: FrostSigningItem): String { + val message = ByteVector(item.eventId.hexToByteArray()) val signerIds = listOf(0, 1) val nonces = signerIds.map { signerId -> @@ -154,8 +147,8 @@ class SignedArtifactTest { /** Everything from the form to the row a device holds afterwards. */ private fun signedArtifactEvent(versionLabel: String = "1.0"): ArtifactEvent { - val session = sessionOver(unsignedEventOf(proposalTemplate(versionLabel))) - val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + val item = itemOver(unsignedEventOf(proposalTemplate(versionLabel))) + val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) return ArtifactEvent( signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig @@ -193,15 +186,15 @@ class SignedArtifactTest { // to be the one that was signed rather than anything recomputed from the // proposer. Otherwise members converge on nothing and each holds its own // copy of what is meant to be one artifact. - val session = sessionOver(unsignedEventOf(proposalTemplate())) - val signed = FrostSigningManager.signedEvent(session, groupSignature(session)) + val item = itemOver(unsignedEventOf(proposalTemplate())) + val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) val artifact = MantraArtifact.fromArtifactEvent( ArtifactEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig), chatRoomId ) - assertEquals(session.eventId, artifact?.id) + assertEquals(item.eventId, artifact?.id) } @Test diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt new file mode 100644 index 00000000..f63474ad --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt @@ -0,0 +1,98 @@ +package press.mantra.compose.nostr.frost + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * How a proposal's events go on the wire, and what an old build makes of them. + * + * The whole reason this encoding has two shapes rather than one is + * compatibility, and compatibility is exactly the thing no other test in this + * area can see: a build that predates batching is not here to be run against. + * So the property is asserted from the other side -- a batch of one is byte for + * byte the JSON object it always was, which is the only form such a build can + * read. + */ +class FrostProposalCodecTest { + private fun event(content: String, kind: Int = 1) = Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000L, + kind = kind, + tags = arrayOf(arrayOf("alt", "test")), + content = content, + sig = "" + ) + + @Test + fun `a batch of one is the bare event, exactly as it always was`() { + val one = event("just this") + + // The load-bearing assertion. A device on a build that predates batching + // parses this with Event.fromJsonOrNull; anything but the bare object + // returns null there and the proposal is dropped, so every single-event + // session in a mixed-version group would stop working. + assertEquals(one.toJson(), FrostSigningEvents.encodeProposal(listOf(one))) + } + + @Test + fun `a real batch is an array`() { + val encoded = FrostSigningEvents.encodeProposal( + listOf(event("first"), event("second")) + ) + + assertTrue(encoded.startsWith("["), "a batch must be a JSON array") + assertTrue(encoded.endsWith("]")) + } + + @Test + fun `what is encoded is what comes back, in order`() { + val events = listOf(event("first"), event("second"), event("third")) + + val decoded = FrostSigningEvents.decodeProposal( + FrostSigningEvents.encodeProposal(events) + ) + + // Order is the batch's index, and every join of nonces and partial + // signatures is positional against it. + assertEquals( + events.map { it.content }, + decoded?.map { it.content } + ) + } + + @Test + fun `a bare event decodes as a batch of one, forever`() { + // Proposals in the old shape do not stop arriving just because this build + // stopped writing them. + val decoded = FrostSigningEvents.decodeProposal(event("from an older build").toJson()) + + assertEquals(1, decoded?.size) + assertEquals("from an older build", decoded?.single()?.content) + } + + @Test + fun `an array with one unreadable element is refused whole`() { + // Not shortened. The batch's length is what every later payload is checked + // against, so a proposal that quietly lost an event would have every + // signer's contribution rejected for being the wrong size. + val encoded = """[${event("fine").toJson()},{"not":"an event"}]""" + + assertNull(FrostSigningEvents.decodeProposal(encoded)) + } + + @Test + fun `an empty array is not a proposal`() { + assertNull(FrostSigningEvents.decodeProposal("[]")) + } + + @Test + fun `nonsense is not a proposal`() { + assertNull(FrostSigningEvents.decodeProposal("[this is not json")) + assertNull(FrostSigningEvents.decodeProposal("")) + assertNull(FrostSigningEvents.decodeProposal("null")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt index 2e03b6f7..d2c8a669 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatRoom 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.NostrEvent import press.mantra.compose.database.model.Profile @@ -81,6 +82,7 @@ class FrostSigningSessionDaoJvmTest { createdAt: Instant = Instant.fromEpochSeconds(1_000), stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, signature: String? = null, + events: Int = 1, ): FrostSigningSession = FrostSigningSession( id = id, chatRoomId = chatRoomId, @@ -91,12 +93,33 @@ class FrostSigningSessionDaoJvmTest { participantCount = 3, signerId = 1, stage = stage, - unsignedEventJson = "{}", - eventId = "ff".repeat(32), - nonceRandom = "aa".repeat(32), - signature = signature, createdAt = createdAt, - ).also { db.frostSigningSessionDao().upsert(it) } + ).also { + db.frostSigningSessionDao().upsert(it) + db.frostSigningSessionDao().upsertItems( + (0 until events).map { index -> item(id, index, signature) } + ) + } + + /** + * One event of a session's batch. Distinct ids and seeds per index, because + * that is what the manager writes and what every ordering assertion here + * would otherwise be blind to. + */ + private fun item( + sessionId: String, + itemIndex: Int = 0, + signature: String? = null, + aggregatedNonce: String? = null, + ) = FrostSigningItem( + sessionId = sessionId, + itemIndex = itemIndex, + unsignedEventJson = """{"index":$itemIndex}""", + eventId = "f$itemIndex".repeat(32), + nonceRandom = "a$itemIndex".repeat(32), + aggregatedNonce = aggregatedNonce, + signature = signature, + ) private suspend fun message( sessionId: String, @@ -242,6 +265,88 @@ class FrostSigningSessionDaoJvmTest { val found = assertNotNull(db.frostSigningSessionDao().getSessionById("s1")) assertEquals(FrostSigningStage.COMPLETE, found.stage) - assertEquals("ab".repeat(32), found.signature) + assertEquals("ab".repeat(32), db.frostSigningSessionDao().getItems("s1").single().signature) + } + + /** + * A batch's items come back in the order the proposal fixed, whatever order + * they went in. + * + * Not a presentation detail. Nonces and partial signatures are joined + * positionally against this list, so a device reading a batch in a different + * order aggregates item 2's partial signature against item 0's message and + * produces a signature nobody can verify. + */ + @Test + fun `a batch's items read back in index order`() = runBlocking { + seedRooms() + session("s1") + + db.frostSigningSessionDao().upsertItems( + listOf(item("s1", itemIndex = 3), item("s1", itemIndex = 1), item("s1", itemIndex = 2)) + ) + + val items = db.frostSigningSessionDao().getItems("s1") + + assertEquals(listOf(0, 1, 2, 3), items.map { it.itemIndex }) + assertEquals(4, db.frostSigningSessionDao().countItems("s1")) + } + + /** + * A redelivered item overwrites rather than accumulates, which is what the + * composite key buys — the same property the signer messages rely on, and for + * the same reason: a second row would make the batch the wrong length, and + * the length is what every payload is checked against. + */ + @Test + fun `a second write of one item replaces it`() = runBlocking { + seedRooms() + session("s1") + + db.frostSigningSessionDao().upsert(item("s1", signature = "cd".repeat(32))) + + val items = db.frostSigningSessionDao().getItems("s1") + + assertEquals(1, items.size) + assertEquals("cd".repeat(32), items.single().signature) + } + + /** Counted rather than flagged, so "the group has finished" cannot disagree with the rows. */ + @Test + fun `signed items are counted apart from the rest`() = runBlocking { + seedRooms() + session("s1", events = 3) + + assertEquals(3, db.frostSigningSessionDao().countItems("s1")) + assertEquals(0, db.frostSigningSessionDao().countSignedItems("s1")) + + db.frostSigningSessionDao().upsertItems( + listOf(item("s1", 0, signature = "ab".repeat(32)), item("s1", 1, signature = "cd".repeat(32))) + ) + + assertEquals(2, db.frostSigningSessionDao().countSignedItems("s1")) + } + + /** Items belong to their session and go with it, like the signer messages do. */ + @Test + fun `deleting a room takes its sessions' items with it`() = runBlocking { + seedRooms() + session("s1", chatRoomId = roomOne, events = 2) + session("s2", chatRoomId = roomTwo, events = 2) + + db.chatRoomDao().delete(assertNotNull(db.chatRoomDao().findChatRoomById(roomOne)).chatRoom) + + assertEquals(emptyList(), db.frostSigningSessionDao().getItems("s1")) + assertEquals(2, db.frostSigningSessionDao().getItems("s2").size) + } + + /** The single-item read the manager uses to pick one event out of a batch. */ + @Test + fun `an item can be read by its index`() = runBlocking { + seedRooms() + session("s1", events = 3) + + assertEquals("f2".repeat(32), db.frostSigningSessionDao().getItem("s1", 2)?.eventId) + assertNull(db.frostSigningSessionDao().getItem("s1", 3)) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt new file mode 100644 index 00000000..6a2e3ffd --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/migrations/FrostSigningItemMigrationJvmTest.kt @@ -0,0 +1,186 @@ +package press.mantra.compose.database.migrations + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The v9 -> v10 backfill, against a real database holding a real half-finished + * session. + * + * The stake is higher than a migration test usually carries. A session in flight + * at upgrade time holds `nonceRandom`, the seed its secret nonce is regenerated + * from, and `aggregatedNonce`, the aggregate its partial signature is made + * against. If either fails to arrive at item 0, the session's next pass derives a + * *different* nonce for the same message and publishes a second partial signature + * over it -- two partial signatures, one message, two nonces, which is how a + * secret share is extracted. So this asserts the values, not merely that a row + * appeared. + * + * Run against the migration's own SQL on a bare connection rather than through + * Room. That covers the copy and the shape it leaves behind, which is what this + * migration is; it does not cover Room's version wiring, which belongs to + * `PlatformDatabaseBuilder` and is the same for every migration in that list. + */ +class FrostSigningItemMigrationJvmTest { + + private val connection: SQLiteConnection = BundledSQLiteDriver().open(":memory:") + + @AfterTest + fun close() = connection.close() + + /** v9's `FrostSigningSession`, verbatim from `schemas/9.json`. */ + private fun createV9() { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `FrostSigningSession` (`id` TEXT NOT NULL, " + + "`chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, " + + "`userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, " + + "`threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, " + + "`signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, " + + "`unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, " + + "`nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signerIds` TEXT, " + + "`signature` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, " + + "`approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, " + + "`updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), " + + "FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` " + + "ON `FrostSigningSession` (`chatRoomId`)" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` " + + "ON `FrostSigningSession` (`dkgSessionId`)" + ) + } + + /** A session mid-flight: it has its seed, and the aggregate it is signing against. */ + private fun insertV9Session( + id: String = "s1", + aggregatedNonce: String? = null, + signature: String? = null, + ) = connection.execSQL( + "INSERT INTO `FrostSigningSession` VALUES (" + + "'$id', 'room', 'coord', 'user', 'dkg-1', 2, 3, 1, 'm/9420/0/0', 'COLLECTING_NONCES', " + + "'$unsignedEventJson', '$eventId', '$nonceRandom', " + + "${aggregatedNonce.orNull()}, '0,1', ${signature.orNull()}, " + + "NULL, 1, 1, 1000, 1000, 1000)" + ) + + private fun String?.orNull(): String = this?.let { "'$it'" } ?: "NULL" + + /** Column names in declaration order, which is what Room checks the table against. */ + private fun columns(table: String): List = + connection.prepare("PRAGMA table_info(`$table`)").use { statement -> + buildList { while (statement.step()) add(statement.getText(1)) } + } + + /** One column of the one row, as text, or null when the column is null. */ + private fun read(table: String, column: String): String? = + connection.prepare("SELECT `$column` FROM `$table`").use { statement -> + if (statement.step() && !statement.isNull(0)) statement.getText(0) else null + } + + private val unsignedEventJson = """{"kind":1}""" + private val eventId = "ff".repeat(32) + private val nonceRandom = "aa".repeat(32) + private val aggregatedNonce = "bb".repeat(66) + private val signature = "cc".repeat(32) + + @Test + fun `an in-flight session keeps the seed and the aggregate it is already signing against`() = + runBlocking { + createV9() + insertV9Session(aggregatedNonce = aggregatedNonce) + + MIGRATION_9_10.migrate(connection) + + // The two that cannot be regenerated. Losing either turns this + // session's next pass into a second partial signature over one message. + assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom")) + assertEquals(aggregatedNonce, read("FrostSigningItem", "aggregatedNonce")) + + assertEquals("0", read("FrostSigningItem", "itemIndex")) + assertEquals(eventId, read("FrostSigningItem", "eventId")) + assertEquals(unsignedEventJson, read("FrostSigningItem", "unsignedEventJson")) + } + + @Test + fun `a finished session keeps its signature`() = runBlocking { + createV9() + insertV9Session(aggregatedNonce = aggregatedNonce, signature = signature) + + MIGRATION_9_10.migrate(connection) + + assertEquals(signature, read("FrostSigningItem", "signature")) + } + + /** A session that had not been aggregated yet arrives with nothing invented for it. */ + @Test + fun `a session still collecting nonces gets an item with no aggregate`() = runBlocking { + createV9() + insertV9Session() + + MIGRATION_9_10.migrate(connection) + + assertEquals(null, read("FrostSigningItem", "aggregatedNonce")) + assertEquals(null, read("FrostSigningItem", "signature")) + assertEquals(nonceRandom, read("FrostSigningItem", "nonceRandom")) + } + + /** + * The shape Room checks on the next open. A column left behind, or one + * missing, is refused at open time -- a crash on launch rather than a bug + * anybody gets to debug. + */ + @Test + fun `the tables are left with exactly the columns v10 declares`() = runBlocking { + createV9() + insertV9Session() + + MIGRATION_9_10.migrate(connection) + + assertEquals( + listOf( + "id", "chatRoomId", "coordinatorPublicKey", "userPublicKey", "dkgSessionId", + "threshold", "participantCount", "signerId", "derivationPath", "stage", + "signerIds", "failureReason", "signApprovedAt", "approvalRequestedAt", + "createdAt", "updatedAt", "savedAt", + ), + columns("FrostSigningSession") + ) + assertEquals( + listOf( + "sessionId", "itemIndex", "unsignedEventJson", "eventId", + "nonceRandom", "aggregatedNonce", "signature", + ), + columns("FrostSigningItem") + ) + } + + /** `signerIds` is shared by every item of a batch, so it stays where it was. */ + @Test + fun `the signer set stays on the session`() = runBlocking { + createV9() + insertV9Session() + + MIGRATION_9_10.migrate(connection) + + assertEquals("0,1", read("FrostSigningSession", "signerIds")) + } + + /** An empty table migrates to an empty table rather than to one bad row. */ + @Test + fun `a database with no sessions gets no items`() = runBlocking { + createV9() + + MIGRATION_9_10.migrate(connection) + + assertEquals(null, read("FrostSigningItem", "sessionId")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt new file mode 100644 index 00000000..b4808780 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt @@ -0,0 +1,224 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatMessage +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * What a group gets for choosing "robust": the key ceremony its room opens with. + * + * The quorum picked while creating the group has nothing in NIP-17 to govern -- + * there is no group state to change, and a different member set is simply a + * different room -- so the only thing that can carry it is a t-of-n key the + * members generate together. Everything that ceremony needs is settled the + * moment the room exists, so `SelectChatRoomTypeViewModel.createNip17ChatRoom` + * opens one there and then rather than leaving it for somebody to find a button + * for. These are the properties that has to hold to. + * + * The proposal being the room's *first* event is not a nicety. Standing up a + * NIP-17 room sends nothing to anybody, so until something goes out the group + * exists on one device only; the proposal is what the other members hear the + * room from at all, which is why `NostrDao` builds the room from a DKG payload's + * p-tags on the way in. + * + * The view model itself is not exercised here -- it needs a wallet's key manager + * and a Compose runtime -- so what stays uncovered is the wiring: that the + * quorum reaching [ChillDkgRitualManager.proposeRitual] is the one the picker + * holds, and that a null session leaves the user on the creation screen. + */ +class RobustRoomKeyCeremonyTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + // Real keys throughout: the room id is derived by doing point work over the + // member set, and the host key each member is identified by for the ceremony + // is derived from their nostr secret. Hex filler exercises neither. + private val creator = KeyPair() + private val user = creator.pubKey.toHexKey() + private val creatorPrivateKey = creator.privKey!! + private val alice = KeyPair().pubKey.toHexKey() + private val bob = KeyPair().pubKey.toHexKey() + + /** The quorum a three-member group is offered by default, and picks here. */ + private val quorum = 2 + + private suspend fun seedProfile(publicKey: String) { + val nostrEventId = publicKey.take(63) + "f" + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert( + Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId) + ) + } + + /** The room the creation screen leaves behind for a robust group. */ + private suspend fun robustRoom(): LocalChatRoom { + listOf(user, alice, bob).forEach { seedProfile(it) } + + return assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = listOf(alice, bob), + subject = "Robust Group", + ), + "createNip17ChatRoom returned null", + ) + } + + private suspend fun openCeremony(room: LocalChatRoom, threshold: Int = quorum) = + ChillDkgRitualManager.proposeRitual( + database = db, + localChatRoom = room, + userPublicKey = user, + nostrPrivateKey = creatorPrivateKey, + threshold = threshold, + ) + + private suspend fun ritualPayloads(room: LocalChatRoom) = + db.giftWrapPayloadDao().getByChatRoomAndKinds(room.chatRoom.id, DkgRitualEvents.ALL.toList()) + + /** The room's lines in the order they were written, id being the insertion order. */ + private suspend fun chatMessages(room: LocalChatRoom) = + db.chatMessageDao().getChatMessagesByChatRoomId(room.chatRoom.id) + .map { it.chatMessage } + .sortedBy { it.id } + + /** + * The whole of the change: a robust room has nothing in it until the ceremony, + * and the ceremony is the first thing in it. + */ + @Test + fun `a robust room's first message is the key ceremony it opens with`() = runBlocking { + val room = robustRoom() + + assertTrue(chatMessages(room).isEmpty(), "the room says nothing until something is sent") + assertTrue(ritualPayloads(room).isEmpty(), "and nothing has gone out yet") + + val session = openCeremony(room) + + assertEquals( + ChatMessage.TYPE_DKG_STARTED, + chatMessages(room).first().messageType, + "the first line in a robust room is its ceremony opening", + ) + + val proposal = ritualPayloads(room).first() + assertEquals(DkgRitualEvents.PROPOSAL, proposal.kind, "and the first event out is the proposal") + assertEquals(user, proposal.publicKey) + + // What every receiver rebuilds the ceremony from: `n` is the p-tag set plus + // the sender, and `t` is the quorum the group was created on. Both are read + // off this event rather than off the receiver's own view of the room, which + // is the thing that drifts. + assertEquals( + setOf(user, alice, bob), + proposal.participantPTags().map { it.pubKey }.toSet(), + "the proposal carries the whole membership", + ) + assertEquals(quorum, DkgRitualEvents.parseThreshold(proposal.tags)) + assertEquals(session.id, DkgRitualEvents.parseSessionId(proposal.tags)) + + assertEquals(quorum, session.threshold, "the ceremony runs at the quorum that was picked") + assertEquals(3, session.participantCount) + assertEquals(user, session.coordinatorPublicKey, "the room's creator coordinates") + + // Opening a ceremony is the act of agreeing to be in it, so this member is + // not asked again -- and their host key goes out with the proposal. + assertNotNull(session.hostKeyApprovedAt) + assertEquals( + 1, + ritualPayloads(room).count { it.kind == DkgRitualEvents.HOST_KEY }, + "the coordinator joins the ceremony it opened", + ) + } + + /** + * Creation is re-enterable -- the room id is derived from the member set, so the + * same group made twice is the same room -- and the ceremony has to be + * re-enterable with it. A second proposal is a second participant set for every + * member to reconcile, and the key the first one produced would be left with + * nothing pointing at it. + */ + @Test + fun `making the same group twice does not open a second ceremony`() = runBlocking { + val room = robustRoom() + val first = openCeremony(room) + + val again = assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = listOf(alice, bob), + subject = "Robust Group", + ) + ) + assertEquals(room.chatRoom.id, again.chatRoom.id, "the same members are the same room") + + // Asked for a different quorum this time: the running ceremony settled that + // question when it opened, and answers with itself rather than the ask. + val second = openCeremony(again, threshold = 3) + + assertEquals(first.id, second.id) + assertEquals(quorum, second.threshold) + assertEquals( + 1, + ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL }, + "one proposal, not two", + ) + assertEquals( + 1, + chatMessages(room).count { it.messageType == ChatMessage.TYPE_DKG_STARTED }, + "and the room is told once", + ) + } + + /** + * A collapsed ceremony leaves the group with no key and a room they can still + * talk in, which is exactly the group that should be able to try again. Failed + * is the one state that does not hold the room's ceremony slot. + */ + @Test + fun `a failed ceremony is replaced rather than handed back`() = runBlocking { + val room = robustRoom() + val abandoned = openCeremony(room) + + db.dkgSessionDao().upsert(abandoned.copy(stage = DkgRitualStage.FAILED)) + + val replacement = openCeremony(room) + + assertNotEquals(abandoned.id, replacement.id) + assertEquals(DkgRitualStage.COLLECTING_HOST_KEYS, replacement.stage) + assertEquals( + 2, + ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL }, + "the group proposes again", + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index b9a822ad..9a3dfc45 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -14,11 +14,13 @@ import fr.acinq.bitcoin.crypto.frost.KeyMaterial import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgParticipantMessage @@ -121,6 +123,13 @@ class SignedGroupKeyStateTest { suspend fun session(sessionId: String): FrostSigningSession? = db.frostSigningSessionDao().getSessionById(sessionId) + /** The events a session signs, in the order its proposal fixed. */ + suspend fun items(sessionId: String) = + db.frostSigningSessionDao().getItems(sessionId) + + /** The one event a session of one signs. */ + suspend fun item(sessionId: String) = items(sessionId).single() + suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId) } @@ -275,7 +284,7 @@ class SignedGroupKeyStateTest { val payload = Event.fromJson(proposal.content) assertEquals(GroupKeyStateEvent.KIND, payload.kind) assertEquals(thresholdPublicKey, payload.content) - assertEquals(session.eventId, payload.id) + assertEquals(creator.item(session.id).eventId, payload.id) } @Test @@ -289,7 +298,7 @@ class SignedGroupKeyStateTest { key = ceremonyOn(creator) ) - val payload = Event.fromJson(session.unsignedEventJson) + val payload = Event.fromJson(creator.item(session.id).unsignedEventJson) // Signing runs at the room's derivation path, so the key the group signs // as is the room's own id. Not the group's root key, which is what an @@ -320,7 +329,7 @@ class SignedGroupKeyStateTest { // Rebuilt from the event's own fields under this device's own reading of // the room. Agreeing on the id is agreeing on every byte that is signed, // the author included. - assertEquals(session.eventId, received.eventId) + assertEquals(creator.item(session.id).eventId, other.item(session.id).eventId) assertEquals(session.derivationPath, received.derivationPath) } @@ -337,7 +346,12 @@ class SignedGroupKeyStateTest { ) pump(creator, other) - assertTrue(FrostSigningManager.isAwaitingApproval(other.session(session.id)!!)) + assertTrue( + FrostSigningManager.isAwaitingApproval( + other.session(session.id)!!, + other.items(session.id) + ) + ) assertTrue( outbox(other).isEmpty(), "a device that has not been asked yet must publish nothing" @@ -394,7 +408,7 @@ class SignedGroupKeyStateTest { FrostSigningManager.approve(other.db, other.room, session.id) pump(creator, other) - val signed = FrostSigningManager.signedEvent(creator.session(session.id)!!) + val signed = FrostSigningManager.signedEvent(creator.item(session.id)) assertNotNull(signed, "a completed session must carry a signed event") assertEquals(adminRoomId, signed.pubKey) @@ -446,6 +460,313 @@ class SignedGroupKeyStateTest { assertEquals(adminRoomId, absent.keyState()?.announcedBy) } + // ---- Signing several events in one session ----------------------------- + + /** + * The batch, end to end, between two devices over two databases: three + * events, one signer set, one approval, and three signatures that verify + * against the room. + * + * Everything about batching that could be wrong and still compile is wrong + * here or nowhere -- a shared nonce, a mismatched order, a payload split the + * wrong way. A signature that verifies is the only evidence any of it is + * wired up correctly, and there are three of them to disagree. + */ + @Test + fun `a batch of three is signed in one session`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val items = device.items(session.id) + + assertEquals(3, items.size, "every device signs the whole batch") + assertEquals(FrostSigningStage.COMPLETE, device.session(session.id)?.stage) + + items.forEach { item -> + assertTrue( + Nip01Crypto.verify( + signature = assertNotNull(item.signature).hexToByteArray(), + hash = item.eventId.hexToByteArray(), + pubKey = adminRoomId.hexToByteArray() + ), + "item ${item.itemIndex} must verify against the room it was signed in" + ) + } + } + } + + /** + * The one that catches the mistake this whole design exists to prevent. + * + * Every positive test above still passes if two items share a nonce -- the + * signatures verify perfectly well. What sharing costs is the secret share, + * to anyone who sees both partial signatures. So the aggregates and the seeds + * are asserted pairwise distinct, which is cheap and is the only assertion + * here that an index bug cannot slip past. + */ + @Test + fun `no two events in a batch share a nonce`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val items = device.items(session.id) + + assertEquals(3, items.mapNotNull { it.aggregatedNonce }.toSet().size) + assertEquals(3, items.map { it.nonceRandom }.toSet().size) + assertEquals(3, items.map { it.eventId }.toSet().size) + } + + // A seed is this device's own, so the two devices must not have arrived + // at the same ones either. + assertEquals( + emptySet(), + creator.items(session.id).map { it.nonceRandom }.toSet() + .intersect(other.items(session.id).map { it.nonceRandom }.toSet()) + ) + } + + /** Four messages for three events, which is the whole point of batching. */ + @Test + fun `a batch costs one round of messages, not one per event`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + // Proposal, nonce, signer set, partial signature, signature. + assertEquals( + listOf( + FrostSigningEvents.PROPOSAL, + FrostSigningEvents.NONCE, + FrostSigningEvents.SIGNER_SET, + FrostSigningEvents.PARTIAL_SIGNATURE, + FrostSigningEvents.SIGNATURE, + ), + outbox(creator).map { it.kind } + ) + + // The other member is a signer and not the coordinator: one nonce and one + // partial signature, each carrying all three values. + assertEquals( + listOf(FrostSigningEvents.NONCE, FrostSigningEvents.PARTIAL_SIGNATURE), + outbox(other).map { it.kind } + ) + outbox(other).forEach { queued -> + assertEquals(3, queued.content.split(",").size, "kind ${queued.kind} must carry three values") + } + + // And one question put to the member, not three. + assertEquals( + 1, + other.db.chatMessageDao().getChatMessagesByChatRoomId(other.roomId) + .count { it.chatMessage.messageType == ChatMessage.TYPE_FROST_APPROVAL_NEEDED } + ) + } + + /** Every event of a batch is applied, not just the first. */ + @Test + fun `every dialect in a batch lands on every device`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val names = device.items(session.id).map { item -> + assertNotNull( + device.db.mantraDialectDao().getDialectById(item.eventId), + "item ${item.itemIndex} should have been applied" + ).name + } + + assertEquals(listOf("Sepedi", "isiZulu", "Setswana"), names) + } + } + + /** + * A proposal is the one place a remote party decides how much work everybody + * else does, so the size cap is checked on arrival and not only when + * proposing. + */ + @Test + fun `a batch larger than the cap is refused when proposed`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + + val tooMany = (0..FrostSigningManager.MAX_BATCH_SIZE).map { index -> + DialectEvent.build(name = "d$index", country = "ZA", language = "l$index") + } + + assertFailsWith { + FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = tooMany + ) + } + + Unit + } + + /** + * A payload that is not the batch's length is left out, not truncated -- and + * leaving it out stalls the session rather than poisoning it. + * + * The length check is the only thing standing between a batch and a signer + * whose contribution lines up against the wrong messages. Truncating a long + * payload or padding a short one would produce partial signatures aggregated + * against events nobody agreed to. + */ + @Test + fun `a nonce payload of the wrong length is left out rather than truncated`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + + // One nonce offered for a batch of three, from a member the coordinator + // would otherwise have picked. + FrostSigningManager.processSigningPayload( + database = creator.db, + localChatRoom = creator.room, + innerEvent = Event( + id = "1".repeat(64), + pubKey = other.publicKey, + createdAt = 1, + kind = FrostSigningEvents.NONCE, + tags = FrostSigningEvents.assembleTags(session.id), + content = "aa".repeat(66), + sig = "" + ), + userPublicKey = creator.publicKey + ) + + assertNull( + creator.session(session.id)?.signerIds, + "a payload of the wrong length must not count towards the threshold" + ) + + // And it is a stall, not damage: the real nonce replaces it and the batch + // finishes. That is the composite key on FrostSignerMessage doing its job. + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + assertEquals(FrostSigningStage.COMPLETE, creator.session(session.id)?.stage) + assertEquals(3, creator.items(session.id).count { it.signature != null }) + } + + /** + * A second proposal under a session's own id cannot change what it signs. + * + * The rule the whole design rests on. Every item's nonce seed is already + * committed to that item's message; giving the session a different batch + * would have those seeds produce a second partial signature over a second + * message, which is how a secret share is extracted. + */ + @Test + fun `a second proposal under the same id cannot change what a session signs`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + + val before = other.items(session.id) + + val substitute = DialectEvent.build(name = "Xitsonga", country = "ZA", language = "tso") + FrostSigningManager.processSigningPayload( + database = other.db, + localChatRoom = other.room, + innerEvent = Event( + id = "2".repeat(64), + pubKey = creator.publicKey, + createdAt = 1, + kind = FrostSigningEvents.PROPOSAL, + tags = FrostSigningEvents.assembleTags(session.id, dkgSessionId = ceremonyId), + content = FrostSigningEvents.encodeProposal( + listOf( + Event( + id = "3".repeat(64), + pubKey = adminRoomId, + createdAt = substitute.createdAt, + kind = substitute.kind, + tags = substitute.tags, + content = substitute.content, + sig = "" + ) + ) + ), + sig = "" + ), + userPublicKey = other.publicKey + ) + + assertEquals( + before.map { it.eventId }, + other.items(session.id).map { it.eventId }, + "a re-proposal must be ignored, not applied" + ) + assertEquals(before.map { it.nonceRandom }, other.items(session.id).map { it.nonceRandom }) + } + + /** Three dialects, distinct enough that an order bug shows up as a wrong name. */ + private fun dialects() = listOf( + Triple("Sepedi", "ZA", "nso"), + Triple("isiZulu", "ZA", "zul"), + Triple("Setswana", "ZA", "tsn"), + ).map { (name, country, language) -> + DialectEvent.build(name = name, country = country, language = language) + } + // ---- What a room signs as, once it has a key state --------------------- @Test @@ -467,7 +788,7 @@ class SignedGroupKeyStateTest { pump(creator, other) listOf(creator, other).forEach { device -> - val dialect = device.db.mantraDialectDao().getDialectById(session.eventId) + val dialect = device.db.mantraDialectDao().getDialectById(device.item(session.id).eventId) assertNotNull(dialect, "a signed dialect should exist on every signer's device") assertEquals("Sepedi", dialect.name) // The ask this whole change serves: the group's work is authored by @@ -601,7 +922,7 @@ class SignedGroupKeyStateTest { ) assertEquals(SharedKeyDerivation.formatPath(sibling), session.derivationPath) - assertEquals(siblingRoomId, Event.fromJson(session.unsignedEventJson).pubKey) + assertEquals(siblingRoomId, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey) pump(creator, other) FrostSigningManager.approve(other.db, other.room, session.id) @@ -640,6 +961,6 @@ class SignedGroupKeyStateTest { assertNull(session.derivationPath, "no path reaches a room that was not derived") assertEquals(emptyList(), session.pathIndices()) - assertEquals(rootPublicKey, Event.fromJson(session.unsignedEventJson).pubKey) + assertEquals(rootPublicKey, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey) } } diff --git a/docs/README.md b/docs/README.md index af213bf5..a58845e7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ 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 | +| [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it | | [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 | | [marmot-direct-messages.md](./marmot-direct-messages.md) | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it | | [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 | @@ -19,4 +20,6 @@ Start with the ceremony if you are new to this area; the Marmot notes all assume 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. The sync note stands alone, and the dead-code inventory reads as a follow-up to it. The +batch-signing note is a phased plan that has been built: read it after the +derivation note, whose one rule is the same one it is built around. The jvm-target note is unrelated to all of them: it is a build and packaging story. diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md new file mode 100644 index 00000000..a55eb573 --- /dev/null +++ b/docs/frost-batch-signing.md @@ -0,0 +1,527 @@ +# Batch signing with FROST + +How to have a group sign several events in one ceremony instead of one at a +time, phased, with the cryptographic constraint that shapes every phase stated +first. + +**Built.** All seven phases are in, one commit each, and the phases below are +kept as written -- they are the reasoning, and the code is easier to read +against the argument it came from than against a summary of itself. Where the +implementation chose differently from the first draft the section says so. +`FrostSigningManager.proposeSigningBatch` and +`FrostSigningRepository.proposeSigningBatch` are the entry points; nothing in +the app calls them yet, which is Phase 7's point. + +The headline: **there is no such thing as one FROST signature over many +messages, and no way to reuse a nonce across them.** What can be batched is the +ceremony — the rounds, the group events, and the approval a human is asked for. +A batch of `k` events is `k` independent FROST instances run in lockstep, +sharing one signer set, one transcript and one prompt. The saving is transport +and UX, not crypto, and that is the saving worth having: today +[FrostSigningManager](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt) +spends five MLS group events and one human decision per event signed. + +## The constraint + +A Schnorr partial signature is `s = k + e·x`, with `e = H(R‖P‖m)`. Two different +messages under the same nonce `R` give two equations in one unknown and the +secret share falls out. That is not a subtlety to be careful around; it is the +one way a t-of-n key is lost, and it is already what the two write-once rules on +[FrostSigningSession](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt) +exist to prevent. + +So the rule every phase below is built to keep: + +> **Every item in a batch has its own independent nonce, from every signer, and +> that nonce signs exactly one message for the life of the session.** + +`fr.acinq.bitcoin.crypto.frost.Session.create` takes the message and the +aggregated nonce, so a batch is `k` `Session` objects sharing a signer set and a +`TweakCache`. There is no batch primitive in the library and none is needed. + +What *is* shared across items, safely, and what is not: + +| shared across the batch | per item | +|---|---| +| signer set (`signerIds`) | nonce seed (`nonceRandom`) | +| ceremony, threshold, participant count | secret + public nonce | +| derivation path and `TweakCache` | aggregated nonce | +| the human approval | `Session`, partial signature, signature | +| the chat transcript | the unsigned event and its id | + +--- + +## Phase 1 — schema + +**Half a day. No wire change, no behaviour change.** + +Five columns move off `FrostSigningSession` onto a new child table: they are the +per-item ones in the table above. + +```kotlin +@Entity( + primaryKeys = ["sessionId", "itemIndex"], + foreignKeys = [ForeignKey( + entity = FrostSigningSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + )], + indices = [Index("sessionId")], +) +data class FrostSigningItem( + val sessionId: String, + /** Position in the batch. Fixed at proposal; it is the wire ordering. */ + val itemIndex: Int, + val unsignedEventJson: String, + val eventId: HexKey, + val nonceRandom: HexKey, + val aggregatedNonce: HexKey? = null, + val signature: HexKey? = null, +) +``` + +`itemIndex` is load-bearing rather than cosmetic: it is the order every device +joins nonces and partial signatures in, so two devices that disagree about it +produce aggregates nobody can verify. It is fixed by the proposal and never +re-sorted. Spelled `itemIndex` rather than `index` because `index` needs quoting +in every hand-written query it appears in, and one missing backtick is a compile +error at best. + +**No `itemCount` column.** The count is `SELECT COUNT(*) FROM FrostSigningItem +WHERE sessionId = :id`, 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 session it describes. + +### Migration 9 → 10 + +Not an auto-migration. Room can add a table but cannot backfill one, and this +migration has to move data before it drops the columns it came from. Manual, in +the shape of `MIGRATION_3_4` — the existing precedent for a migration that is +about data rather than shape. + +```sql +CREATE TABLE FrostSigningItem (...); +INSERT INTO FrostSigningItem (sessionId, itemIndex, unsignedEventJson, eventId, + nonceRandom, aggregatedNonce, signature) + SELECT id, 0, unsignedEventJson, eventId, nonceRandom, aggregatedNonce, signature + FROM FrostSigningSession; +ALTER TABLE FrostSigningSession DROP COLUMN unsignedEventJson; -- and the other four +``` + +**`DROP COLUMN`, not a table rebuild.** The usual SQLite way to remove columns — +create a new table, copy, drop the old one, rename — is unsafe here and quietly +so. `FrostSignerMessage` and the new `FrostSigningItem` both reference +`FrostSigningSession(id)` `ON DELETE CASCADE`, and `DROP TABLE` fires cascades: +with foreign keys enforced it would delete every signer message and every item +the migration had just written. Whether it does depends on Room having turned +foreign keys off around the migration, which is not worth depending on when +`ALTER TABLE ... DROP COLUMN` cannot go wrong. It needs SQLite 3.35 and columns +free of indices and constraints; these five qualify, and `getRoomDatabase` pins +`BundledSQLiteDriver` on every platform, so the SQLite version is ours rather +than the host's. + +**Why the backfill has to be exact.** A session in flight at upgrade time holds +its nonce seed and, possibly, its aggregated nonce in those columns. Losing +either means regenerating a different nonce on the next pass — publishing a +second partial signature over the same message against a different aggregate, +which is the extraction case. Copying them verbatim into item 0 means an +in-flight session resumes as though nothing happened. A migration that dropped +them and let sessions restart would be the one dangerous way to write this. + +Update the entity list and `version = 10` in +[MantraDatabase.kt:175](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt), +regenerate `composeApp/schemas/10.json`, and add the DAO methods: +`getItems(sessionId)`, `observeItems(sessionId)`, `upsertItems(List)`, +`countItems(sessionId)`, `countSignedItems(sessionId)`. + +**Test:** extend +[FrostSigningSessionDaoJvmTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDaoJvmTest.kt) +with item CRUD, the `(sessionId, itemIndex)` dedupe, index ordering and the +cascade delete. Add a migration test that runs the migration over a v9 database +holding a half-finished session and asserts the seed and aggregate survive at +index 0 — the values, not merely that a row appeared. + +--- + +## Phase 2 — vectorise the manager at k = 1 + +**Two days. Still no wire change, still no API change.** + +The whole point of this phase is that **every existing test passes unchanged**. +`proposeSigning` still takes one event, still writes one item, and the messages +on the wire are byte-identical to today's. What changes is that `advance()` +loops. + +In [FrostSigningManager.advance](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt): + +1. Load `items` once, ordered by `index`. +2. Nonce generation becomes a `map` over items, each with its own + `session.nonceRandom` → `item.nonceRandom` and its own `message`. +3. `Session.create` becomes one per item; the `signerIds`, `publicShares`, + `nParticipants`, `threshold` and `tweakCache` arguments are the same for all + of them. +4. `sign` and `aggregateSigs` become per item. +5. `complete()` verifies each item's signature against `item.eventId` and calls + `applySignedEvent` for each. + +Three invariants to establish here, because Phase 3 depends on all three: + +- **The signer set and every aggregated nonce are one write-once unit.** The + coordinator writes `session.signerIds` and all `k` `item.aggregatedNonce` + values in a single `@Transaction`, so "some items aggregated" is unreachable. + `signerIds != null` stays the gate the rest of `advance()` reads, exactly as + today. +- **Signatures likewise.** All `k` land in one transaction; `countSignedItems == + countItems` is the settled test, replacing today's `session.signature != null` + in both `advance()` and `isAwaitingApproval`. +- **Item order is the wire order.** Every join and split goes through one pair + of helpers, never an ad-hoc `map` at a call site. + +### The cost that appears here + +`advance()` regenerates nonces and creates FROST sessions **unconditionally on +every inbound message**, before checking whether this device has already +published. At k=1 that is one native call per message and nobody notices. At +k=64 it is 64 nonce generations, 64 `Session.create`s and 64 signs on every +message the group sends — several hundred native calls to discover there is +nothing to do. + +Fix it in this phase, while it is still cheap to verify: generate nonces on +demand, and leave before building any `Session` when this device is neither +signing nor aggregating. Both are pure optimisations at k=1, which is the point +of doing them before k>1 exists. + +**Generate them lazily, not behind a guard.** The obvious version — compute the +nonces only when `ownNonce == null || (isSigner() && ownPartial == null)` — is +wrong, and wrong in a way that passes a reading and fails every test. The +coordinator settles the signer set *further down the same pass*, so `isSigner()` +read at the top of `advance()` is false on the pass where the coordinator is +about to become a signer, and the nonces it then needs were never generated. A +`by lazy` has no such prediction to make: it generates at first use, at most +once per pass, and never on a pass with nothing to publish. + +### The payload codec, early + +`joinPayload`/`splitPayload` land here rather than in Phase 3, because at k=1 +they are the identity — a one-element comma join is the bare value — so they +change no byte on the wire and leave Phase 3 to the proposal encoding alone. + +**Test:** existing tests are the test. If +[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt) +and +[SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt) +pass without edits beyond the schema move, the vectorisation is faithful. + +--- + +## Phase 3 — k > 1 on the wire + +**Two days. This is the phase with the compatibility trap in it.** + +### Payload encoding + +*(Landed in Phase 2 — see above. Restated here because the rest of this phase +depends on it.)* + +`FrostSignerMessage` keeps its `(sessionId, signerPublicKey, kind)` primary key +and **one row carries all `k` values**, comma-joined — the same encoding +`FrostSigningSession.signerIds` already uses. + +Per-item message rows were the obvious alternative and are worse. The current +key is what makes a redelivered message overwrite rather than accumulate, which +is what keeps the coordinator's signer set the right length; splitting by item +multiplies the ways a partial delivery can look like a complete one. One group +event carrying a signer's whole contribution also matches the transport: a +signer publishes all `k` nonces or none. + +The parse is strict. A payload whose element count is not the session's item +count is dropped, not truncated and not padded: + +```kotlin +private fun splitPayload(payload: String, expected: Int): List? = + payload.split(",").takeIf { it.size == expected } +``` + +Note *where* this runs. `record()` stores payloads without parsing them, which +is what lets a nonce arrive before its proposal; the count check therefore +belongs in `orderedNonces` and `orderedPartialSignatures`, where the session — +and so the count — is known. Do not move it earlier to "fail faster"; that +breaks the out-of-order replay that `replayStoredMessages` exists for. + +### Proposal encoding, and the compatibility trap + +The proposal's content becomes a JSON **array** of unsigned events — but only +when `k > 1`. + +A single-event session must keep serialising as a bare JSON object, byte for +byte as today. The reason is what an old build does with each form: + +| old build receives | outcome | +|---|---| +| bare object (k=1) | signs it, exactly as now | +| JSON array (k>1) | `Event.fromJsonOrNull` returns null → "does not carry an event; dropping" | + +That is the correct failure. An old device refuses a batch rather than +mis-signing part of one, and a group with mixed versions keeps single signing +working throughout the rollout. Emitting an array unconditionally would break +every k=1 session for old devices and buy nothing. + +`acceptProposal` accepts both: array if the content starts with `[`, otherwise a +one-element list. New devices understand old proposals forever; old devices +understand new single proposals forever. + +### `acceptProposal` over a list + +Each element is rebuilt from its own fields under the room's path and checked +against the id it claims — the existing check, per item, and the whole proposal +is dropped if any one fails. The write-once rule extends from "the event this +session signs" to "the ordered list of events this session signs": a second +proposal under the same id whose list differs anywhere is logged and ignored, +never applied. + +### A cap on `k` + +Enforce a maximum batch size — 64 is a sane starting number — in +`proposeSigning` **and independently in `acceptProposal`**. + +The second check is the one that matters. Without it a proposer can hand every +member a batch of arbitrary size and have them do unbounded native work and +publish an unbounded group event, from a single message. The proposal is the one +place in this protocol where a remote party decides how much work everyone else +does, and it is currently bounded only by never having more than one item. + +Size it against the MLS group event limit rather than picking a round number: +each signer's nonce message is `k × 133` bytes of hex-and-commas, the partial +message `k × 65`, and the proposal itself carries `k` whole events, which is the +term that actually binds. 64 to start. + +### `proposeSigningBatch` on the manager, here + +The public API on the manager lands in this phase rather than the next one, for +a plain reason: there is no other way to produce a `k > 1` session, so without it +everything above ships untested. `proposeSigning` becomes its one-event form and +keeps its signature, so no caller moves. Phase 4 is then the repository, the call +sites and the failure policy. + +That also makes this the phase where the batch is proved end to end — a `k = 3` +session between two devices over two databases, plus the negative test that no +two items share a nonce. Both are described under Phase 6 and are worth reading +there; they simply run here, because this is where the thing they test exists. + +--- + +## Phase 4 — the batch API and its call sites + +**A day.** + +*(`FrostSigningManager.proposeSigningBatch` itself landed in Phase 3 — see +above. This phase is what surrounds it.)* + +```kotlin +suspend fun proposeSigningBatch( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List>, // each carries its own createdAt + key: DkgSession? = null, +): FrostSigningSession +``` + +`proposeSigning` stays, as the one-element call into it, so +[AddDialectViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt), +[AddArtifactViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddArtifactViewModel.kt) +and +[GroupKeyStateManager.propose](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt) +need no edit at all. `GroupKeyStateManager` in particular should never batch: a +room's key state is the statement every other session is opened against, and +bundling it with anything else would make it as available as its worst +co-passenger. + +Mirror both on +[FrostSigningRepository](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt), +including the `NO_OP` implementation. `signedEvent(session): Event?` becomes +`signedEvents(session): List`. + +### Nonce seeds + +`k` independent 32-byte seeds, generated at proposal, one per item. Deriving +them from a single session seed by index would work and save nothing worth +having: 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 +one nonce. + +### Failure policy: all or nothing + +A batch fails whole. If any item's aggregation fails, `fail()` runs as it does +today, nothing is applied, and the group is told once. + +This is a real cost and should be stated where callers can see it: **a batch is +only as available as its worst item**, so bundling unrelated events makes both +less likely to get signed. Batch things that belong together. + +The retry rule is the one that must not be got wrong, and deserves a comment on +`proposeSigningBatch` itself: **a retry is a new session id with new seeds.** +Never re-propose a failed batch under its own id, and never reuse an item's +`nonceRandom`. This is true of single sessions today; batches make it more +tempting to get wrong, because a batch that failed on item 5 looks like it has +four perfectly good nonces going spare. It does not — those four have already +been published against an aggregate. + +Per-item partial success is deliberately out of scope. It would need mixed-state +UI, a transcript that can say "3 of 5", and a `complete()` that applies a subset, +which is a lot of surface for an outcome that indicates a bug or a dishonest +coordinator rather than a normal ending. + +--- + +## Phase 5 — UI + +**Two days, most of it in the screen.** + +- `FrostSigningUIState.Loaded` gains `items: List`. +- [FrostSigningViewModel](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt) + combines `observeItems` into its existing `combine`; nothing else changes, + since it already re-renders on every session write. +- [FrostSigningScreen](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt) + renders a list where it renders one event today. + +**The approval gate must show every event, not a count.** The argument in +`FrostSigningManager`'s own header — one approval rather than three, because the +event is fixed before the member is asked — only holds if the member can see +what they are approving. "Sign 12 events?" behind a chevron is a worse prompt +than the twelve prompts it replaces. A member who cannot scroll the whole list +should not be able to approve it. + +`FrostSigningRoute` is unchanged: it already carries a session id, and a batch is +one session. + +### Transcript + +No new `ChatMessage` types. The existing `TYPE_FROST_*` constants, +`FROST_REQUEST_FULFILMENTS`, `FROST_SETTLEMENTS` and `FROST_TYPES` in +[ChatMessage.kt:220](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt) +all work as they stand — one line per step, per member, whatever `k` is. Only +the wording in `announceStep` and `announceStarted` gains a count: "signed their +part of 12 events". This is worth checking rather than assuming, because a new +type would need adding to five sets and would silently render as a chat bubble +if missed from `FROST_TYPES`. + +--- + +## Phase 6 — the test that actually proves it + +**A day, and do not skip it.** + +*(The first two ran in Phase 3, where the batch first existed. Kept here because +this is the section anybody adding to these tests will read.)* + +Extend +[SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt), +which already runs a real signing session between two devices over two +databases, with a `k = 3` batch: both devices reach `COMPLETE`, all three +signatures verify against their own event ids under the room's key, all three +events are applied locally on both, and the whole thing costs five messages from +the coordinator and two from the other signer rather than one round per event. + +**The negative one that matters**: assert the three aggregated nonces are +pairwise distinct, and that the three seeds are. Every positive test above still +passes if two items share a nonce — the signatures verify perfectly well; what +sharing costs is the secret share, to anyone who sees both partial signatures. +It is the cheapest possible guard against the one mistake in this document that +loses the key, and it catches an index bug nothing else here would. + +Then two on the inbound path, driven by handing the manager a hand-built inner +event rather than one the other device queued — which is the only way to be a +faulty or dishonest member in this harness: + +- **a wrong-length payload is left out, not truncated.** The length check is all + that stands between a batch and a signer whose contribution lines up against + the wrong messages. Assert that a one-value nonce for a three-item batch does + not count towards the threshold — and then that the real nonce replaces it and + the batch finishes, so it is a stall rather than damage. +- **a second proposal under the same id changes nothing.** Every item's seed is + already committed to that item's message; a different batch under the same id + would have those seeds produce a second partial signature over a second + message. + +And three in +[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt), +against real FROST with no database, for the same reason that file exists at +all — the library calls are checked with nothing in the way: a `k = 3` batch +from one signer set where all three verify, an item's signature refusing to +verify against its neighbour, and both halves of the no-shared-nonce property +(one seed under two messages gives two nonces, *and* the seeds differ anyway — +either alone is enough to be relied on by accident). + +--- + +## Phase 7 — rollout + +**No code.** + +Nothing here needs a feature flag. `k = 1` is the entire behaviour of the app as +shipped — no caller batches anything yet — and at `k = 1` every message is +byte-identical to the app before Phase 1: + +| message | at `k = 1` | +|---|---| +| proposal | `encodeProposal` returns the bare event object (asserted in `FrostProposalCodecTest`) | +| nonce, signer set, partial, signature | `joinPayload` of one value is that value | +| chat transcript | every line's plural branch is only taken above 1 | + +Phases 1 and 2 are shippable on their own and were worth landing on their own — +a schema move and a refactor — because a bisect over a signing bug then lands on +one or the other rather than on all of it. + +Before the first caller batches, confirm the group is on a build that +understands array proposals. There is no negotiation for this and adding one is +not worth it: the failure mode is a batch that never reaches threshold and is +abandoned, which is visible in the transcript and costs nothing but a retry. + +### What is left, when a caller wants it + +Nothing in the protocol. The remaining work is deciding *what* to batch, which +is a product question this document deliberately does not answer — beyond the +one rule that a batch is only as available as its worst item, so events that do +not belong together should not travel together, and the one prohibition that +`GroupKeyStateManager.propose` must never batch. + +--- + +## Appendix — what was considered and rejected + +**One signature over `k` messages.** Does not exist for Schnorr. Aggregate +signature schemes that do this (BLS) are a different curve and a different +verification story, and nostr verifies BIP-340. + +**One nonce, `k` messages.** This is the extraction attack, described at the top. + +**Nonce pre-processing** — the FROST paper's own batching, where signers +pre-publish a list of π nonce commitments before any message is known, and each +later signature consumes one commitment per signer in a single online round. The +library supports it: `SecretNonce.generate` takes `message` as nullable. + +Rejected for now, and it is worth writing down why, because it is the thing +somebody will suggest next. It cuts *latency* rather than message count, which +is not the complaint. And it costs the property this whole design rests on: +today a nonce seed is safe to store and regenerate from **because** a session +signs one fixed message and cannot be made to sign another. Banked nonces have +no message to be bound to at generation time, so their safety moves from a +structural argument to a used/unused ledger that must be right across crashes, +redeliveries and two devices. A bug there leaks a share. Revisit only if +round-trip latency becomes the actual problem. + +**`Frost.deterministicSign` (BIP-445)**, where a signer going last derives its +nonce from the other signers' aggregate and persists nothing, is orthogonal to +batching but relevant to the same file. It would let one signer per session hold +no nonce state at all. Not part of this work. + +**Signing one manifest event that commits to `k` items** — a list of ids, or a +Merkle root. One signature, no protocol change, and by far the cheapest thing on +this page. Rejected as the general answer because each item stops carrying its +own verifiable signature, and the whole point of +[shared-key-derivation.md](./shared-key-derivation.md) is that a reader holding +one signed dialect can check it against the room it was found in without a +lookup. Still the right answer for any case where the items are only ever read +together.