diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json new file mode 100644 index 00000000..8c68b8c0 --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/6.json @@ -0,0 +1,5289 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "bce93202d97cf766e17536af771fcffd", + "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, `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": "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": "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, `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 )", + "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": "stage", + "columnName": "stage", + "affinity": "TEXT", + "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": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "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": "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, `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": "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, 'bce93202d97cf766e17536af771fcffd')" + ] + } +} \ 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 e3c146ec..29cd80e7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -16,6 +16,7 @@ import press.mantra.compose.database.dao.ChatMessageNostrEventRelationDao import press.mantra.compose.database.dao.ChatRoomDao import press.mantra.compose.database.dao.ConnectionDao import press.mantra.compose.database.dao.DkgSessionDao +import press.mantra.compose.database.dao.FrostSigningSessionDao import press.mantra.compose.database.dao.GiftWrapMessageDao import press.mantra.compose.database.dao.GiftWrapPayloadDao import press.mantra.compose.database.dao.GiftWrapSealDao @@ -102,6 +103,8 @@ import press.mantra.compose.database.model.Reaction import press.mantra.compose.database.model.RecentSearch import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.Relay import press.mantra.compose.database.model.RepostedRelation import press.mantra.compose.database.model.SynchronizeNostrEventRequest @@ -124,6 +127,8 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) ChatRoom::class, DkgParticipantMessage::class, DkgSession::class, + FrostSignerMessage::class, + FrostSigningSession::class, GiftWrapMessage::class, GiftWrapSeal::class, GiftWrapPayload::class, @@ -164,7 +169,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 5, + version = 6, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -183,7 +188,13 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // nip30303 event a SubmissionEvent rumor carries. Rumors queued before // this come back null, which reads as "not a submission" -- correct, // since none of them were. - AutoMigration(from = 4, to = 5) + AutoMigration(from = 4, to = 5), + // v6 adds the FrostSigningSession/FrostSignerMessage tables and the + // nullable DkgSession.publicShares. New tables and a nullable column are + // both shapes Room can migrate itself. A ceremony that completed before + // this reads back null, and signing falls back to not cross-checking + // shares rather than refusing to run. + AutoMigration(from = 5, to = 6) ] ) @ColumnTypeConverters(MantraConverters::class) @@ -203,6 +214,8 @@ abstract class MantraDatabase: RoomDatabase() { abstract fun dkgSessionDao(): DkgSessionDao + abstract fun frostSigningSessionDao(): FrostSigningSessionDao + abstract fun connectionDao(): ConnectionDao abstract fun giftWrapMessageDao(): GiftWrapMessageDao 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 new file mode 100644 index 00000000..78d04c63 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt @@ -0,0 +1,47 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.coroutines.flow.Flow +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.model.FrostSigningSession + +@Dao +interface FrostSigningSessionDao { + @Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId") + suspend fun getSessionById(sessionId: String): FrostSigningSession? + + @Query("SELECT * FROM FrostSigningSession WHERE id = :sessionId") + fun observeSessionById(sessionId: String): Flow + + /** + * A room's signing sessions, newest first. Unlike a DKG a group signs + * repeatedly, so there is no single "current" one to observe. + */ + @Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC") + fun observeSessionsForChatRoom(chatRoomId: String): Flow> + + @Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1") + suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession? + + @Upsert + suspend fun upsert(frostSigningSession: FrostSigningSession) + + @Upsert + suspend fun upsert(frostSignerMessage: FrostSignerMessage) + + @Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind ORDER BY createdAt ASC") + suspend fun getMessagesByKind(sessionId: String, kind: Kind): List + + @Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId ORDER BY createdAt ASC") + fun observeMessages(sessionId: String): Flow> + + @Query("SELECT COUNT(*) FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind") + suspend fun countMessagesByKind(sessionId: String, kind: Kind): Int + + @Query("SELECT * FROM FrostSignerMessage WHERE sessionId = :sessionId AND kind = :kind AND signerPublicKey = :signerPublicKey") + suspend fun getMessage(sessionId: String, kind: Kind, signerPublicKey: HexKey): FrostSignerMessage? +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 304a048c..3beb8e67 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -31,6 +31,8 @@ import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventI import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.frost.FrostSigningEvents +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.MarmotInboundManager import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException @@ -960,6 +962,29 @@ abstract class NostrDao( nostrPrivateKey = activeKeyPair.privKey!! ) } + } else if (FrostSigningEvents.isFrostSigningKind(decryptedGiftWrapPayload.kind)) { + // A FROST signing session message for one of our + // NIP-17 groups. Same reasoning as the ritual above: + // the manager is idempotent, and the room is created + // on demand because membership is the payload's + // p-tags either way. + val localChatRoom = getOrCreateNip17ChatRoom( + decryptedGiftWrapPayload = decryptedGiftWrapPayload, + activeKeyPair = activeKeyPair, + nostrEventId = nostrEvent.id, + relayURL = relayURL + ) + + if (localChatRoom == null) { + logger.w("FROST payload for unknown chat room ${decryptedGiftWrapPayload.chatRoomId}") + } else { + FrostSigningManager.processSigningPayload( + database = database, + localChatRoom = localChatRoom, + giftWrapPayload = decryptedGiftWrapPayload, + userPublicKey = activeKeyPair.pubKey.toHex() + ) + } } else { logger.w("Unsupported event: $decryptedGiftWrapPayload") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 28e2d0f8..59fca17f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -179,6 +179,51 @@ data class ChatMessage( * A finished ceremony is the exception, and has no actor: the group ends up * with a key, nobody hands it to them. */ + /** + * A FROST signing session, as lines in the group's chat. + * + * The same shape as the ceremony's, and for the same reason: signing with + * the group's key is otherwise a black box, and a session stalled on one + * member gives no way to see whose door to knock on. + */ + const val TYPE_FROST_STARTED = "frostStarted" + const val TYPE_FROST_NONCE = "frostNonce" + const val TYPE_FROST_SIGNER_SET = "frostSignerSet" + const val TYPE_FROST_PARTIAL_SIGNATURE = "frostPartialSignature" + const val TYPE_FROST_SIGNATURE = "frostSignature" + const val TYPE_FROST_COMPLETE = "frostComplete" + const val TYPE_FROST_FAILED = "frostFailed" + + /** Addressed to the reader rather than said by anyone -- see [DKG_REQUEST_TYPES]. */ + const val TYPE_FROST_APPROVAL_NEEDED = "frostApprovalNeeded" + + /** Every signing line, for rendering them as system lines rather than bubbles. */ + val FROST_TYPES = setOf( + TYPE_FROST_STARTED, + TYPE_FROST_NONCE, + TYPE_FROST_SIGNER_SET, + TYPE_FROST_PARTIAL_SIGNATURE, + TYPE_FROST_SIGNATURE, + TYPE_FROST_COMPLETE, + TYPE_FROST_FAILED, + TYPE_FROST_APPROVAL_NEEDED, + ) + + /** + * The signing lines somebody did, as opposed to ones that simply happened. + * Their content is written as a predicate for the actor's name to be read + * in front of. A finished signature has no actor: the group ends up with + * one, nobody hands it to them. + */ + val FROST_AUTHORED_TYPES = setOf( + TYPE_FROST_STARTED, + TYPE_FROST_NONCE, + TYPE_FROST_SIGNER_SET, + TYPE_FROST_PARTIAL_SIGNATURE, + TYPE_FROST_SIGNATURE, + TYPE_FROST_FAILED, + ) + val DKG_AUTHORED_TYPES = setOf( TYPE_DKG_STARTED, TYPE_DKG_HOST_KEY, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt index 24ecc091..8a44166f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/DkgSession.kt @@ -9,6 +9,9 @@ import press.mantra.compose.database.model.traits.TimestampedEntity import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.database.model.types.DkgRitualStage import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.PublicKey import kotlin.time.Clock import kotlin.time.Instant @@ -82,6 +85,17 @@ data class DkgSession( /** Result, secret: this device's FROST secret share, hex. */ val secretShare: HexKey? = null, + /** + * Result: every participant's public share, comma-separated hex, in + * participant order. + * + * Not secret, and not needed to finish the ceremony -- kept because signing + * is what the key is for and FROST validates each signer's secret share + * against its public one. Null on a ceremony that completed before this + * column existed; signing still works there, without that check. + */ + val publicShares: String? = null, + /** Result: recovery data, to be backed up alongside the host key. */ val recoveryData: HexKey? = null, @@ -125,4 +139,11 @@ data class DkgSession( override val savedAt: Instant = createdAt, ): TimestampedEntity, LocalStoreEntity { fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey + + /** The participants' public shares in participant order, or null if unrecorded. */ + fun publicShareList(): List? = publicShares + ?.split(",") + ?.mapNotNull { hex -> hex.trim().takeIf { it.isNotEmpty() } } + ?.map { PublicKey(ByteVector(it.hexToByteArray())) } + ?.takeIf { it.isNotEmpty() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSignerMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSignerMessage.kt new file mode 100644 index 00000000..bfaa1f65 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSignerMessage.kt @@ -0,0 +1,52 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * One signing message from one member, keyed so a duplicate delivery overwrites + * rather than accumulates — relays redeliver, and feeding the same nonce in + * twice would make the coordinator's signer set the wrong length. + * + * Covers the nonce and the partial signature; the coordinator's own broadcasts + * live on [FrostSigningSession] because there is only ever one of each. + * + * Keyed on `(sessionId, signerPublicKey, kind)`, which also means a member + * cannot replace their own nonce once the coordinator has aggregated it — a + * second nonce from the same signer overwrites the first, and the aggregate + * built from it simply stops matching. The session's own write-once rule on the + * aggregate is what makes that a stalled session rather than a leaked share. + */ +@Entity( + primaryKeys = ["sessionId", "signerPublicKey", "kind"], + foreignKeys = [ + ForeignKey( + entity = FrostSigningSession::class, + parentColumns = ["id"], + childColumns = ["sessionId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("sessionId"), + ], +) +data class FrostSignerMessage( + val sessionId: String, + + /** The member's nostr public key — who sent it. */ + val signerPublicKey: HexKey, + + /** One of the `FrostSigningEvents` kinds. */ + val kind: Kind, + + /** Hex of the protocol bytes: a public nonce, or a partial signature. */ + val payload: HexKey, + + val createdAt: Instant = Clock.System.now(), +) 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 new file mode 100644 index 00000000..e15db909 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/FrostSigningSession.kt @@ -0,0 +1,139 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import androidx.room3.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.time.Clock +import kotlin.time.Instant +import press.mantra.compose.database.model.traits.LocalStoreEntity +import press.mantra.compose.database.model.traits.TimestampedEntity +import press.mantra.compose.database.model.types.FrostSigningStage + +/** + * One FROST signing session, as this device sees it. + * + * Like [DkgSession] this stores *inputs* rather than protocol state, so a + * session survives the app being killed halfway through: every step is + * recomputed from what is on the row. Unlike a DKG that choice is not merely + * convenient here, it is forced -- `fr.acinq.bitcoin.crypto.frost.SecretNonce` + * cannot be serialised and refuses to be used twice, by design. + * + * ### The nonce, and why one session means one message + * + * [nonceRandom] is secret, and regenerating this device's nonce from it is safe + * for exactly one reason: a session signs one message and can never be made to + * sign another. `SecretNonce.generate` mixes the message in, so the same + * randomness under a different message would be a different nonce -- but the + * same randomness under the same message with two *different* aggregated nonces + * would produce two partial signatures over one secret nonce, which is how a + * secret share is extracted. + * + * Two rules keep that impossible, and both are load-bearing: + * + * - [eventId] is written when the session is created and a proposal that + * disagrees with it is rejected rather than applied. + * - [aggregatedNonce] and [signerIds] are written once. A second, different + * signer set for the same session is ignored, not honoured. + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = ChatRoom::class, + parentColumns = ["id"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + Index("chatRoomId"), + Index("dkgSessionId"), + ], +) +data class FrostSigningSession( + /** Minted by the proposer, carried on every message as `frost_session`. */ + @PrimaryKey + val id: String, + + val chatRoomId: String, + + /** The member who proposed the signature, who also aggregates for it. */ + val coordinatorPublicKey: HexKey, + + /** Whose device this row belongs to, for multi-account support. */ + val userPublicKey: HexKey, + + /** The ceremony whose key this signs with — a group may hold more than one. */ + val dkgSessionId: String, + + /** The `t` of the t-of-n: how many partial signatures make a signature. */ + val threshold: Int, + + /** The `n` the key was generated for. FROST needs it to place signer ids. */ + val participantCount: Int, + + /** This device's FROST id: its index in the ceremony's participant order. */ + val signerId: Int, + + val stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + + /** + * The unsigned event, as JSON. Kept whole so a member can be shown what + * they are being asked to sign rather than a hash of it. + */ + val unsignedEventJson: String, + + /** + * The event id, which is the 32 bytes actually signed. + * + * Recomputed from the event's own fields on arrival, never taken from the + * proposal. It pins the session to one message -- see the class note on why + * that is what makes reusing [nonceRandom] safe. + */ + val eventId: HexKey, + + /** Secret. 32 bytes of fresh randomness, the seed for this device's nonce. */ + val nonceRandom: HexKey, + + /** The coordinator's `AggregatedNonce` once it arrives, hex. Written once. */ + val aggregatedNonce: HexKey? = null, + + /** The chosen signers' FROST ids in aggregation order, comma separated. Written once. */ + val signerIds: String? = null, + + /** Result: the finished 64-byte BIP-340 signature over [eventId], hex. */ + val signature: HexKey? = null, + + val failureReason: String? = null, + + /** + * When this device's owner agreed to sign, and with it to everything the + * session does on their behalf. Null until they do, and nothing of theirs + * goes out before it is set. + * + * One gate rather than the DKG's three. What a signer is consenting to is + * the event, and the event is fixed before they are asked: the second round + * puts no new question to them, so asking again would be asking the same + * question twice about a decision already made. + */ + val signApprovedAt: Instant? = null, + + /** Whether the chat line asking for that approval has been written. */ + val approvalRequestedAt: Instant? = null, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = createdAt, + override val savedAt: Instant = createdAt, +): TimestampedEntity, LocalStoreEntity { + fun isCoordinator(): Boolean = coordinatorPublicKey == userPublicKey + + /** The chosen signers, or null while the coordinator has yet to choose. */ + fun signerIdList(): List? = signerIds + ?.split(",") + ?.mapNotNull { it.trim().toIntOrNull() } + ?.takeIf { it.isNotEmpty() } + + /** Whether this device was picked to sign. A t-of-n key does not need everyone. */ + fun isSigner(): Boolean = signerIdList()?.contains(signerId) ?: false +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/FrostSigningStage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/FrostSigningStage.kt new file mode 100644 index 00000000..9331e3a7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/FrostSigningStage.kt @@ -0,0 +1,25 @@ +package press.mantra.compose.database.model.types + +/** + * How far a FROST signing session has got, from the point of view of the device + * holding the row. Coordinator and signers move through the same ladder; the + * coordinator simply has extra work to do at [COLLECTING_NONCES] and + * [COLLECTING_PARTIAL_SIGNATURES]. + * + * Declaration order is the ladder: `FrostSigningManager` compares ordinals to + * keep the label moving forwards when messages arrive out of order, so the + * collecting stages must stay in the order the session runs them. + */ +enum class FrostSigningStage { + /** Proposal seen; waiting for enough signers to offer a nonce. */ + COLLECTING_NONCES, + + /** The signer set is fixed; waiting on their partial signatures. */ + COLLECTING_PARTIAL_SIGNATURES, + + /** Aggregated and verified. The event is signed. */ + COMPLETE, + + /** Abandoned. See `FrostSigningSession.failureReason`. */ + FAILED +} 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 79bb84a8..cf20fd29 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -574,6 +574,12 @@ object ChillDkgRitualManager { stage = DkgRitualStage.COMPLETE, thresholdPublicKey = output.thresholdPublicKey?.value?.toHex(), secretShare = output.secretShare?.value?.toHex(), + // Kept for signing, which needs every participant's public + // share to place and check the signers. In participant order, + // the same order the ids are derived from. + publicShares = output.publicShares + .joinToString(",") { share -> share.value.toHex() } + .takeIf { output.publicShares.isNotEmpty() }, recoveryData = output.recovery?.toHex() ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt new file mode 100644 index 00000000..b059106b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -0,0 +1,1135 @@ +package press.mantra.compose.managers + +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.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.RandomInstance +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.PublicKey +import fr.acinq.bitcoin.crypto.frost.AggregatedNonce +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import fr.acinq.bitcoin.crypto.frost.TweakCache +import fr.acinq.bitcoin.utils.Either +import kotlin.time.Clock +import kotlin.time.Instant +import kotlinx.coroutines.CancellationException +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.FrostSigningSession +import press.mantra.compose.database.model.GiftWrapPayload +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.frost.FrostSigningEvents + +/** + * Signs a nostr event with a group's FROST threshold key, over a NIP-17 group. + * + * The shape is [ChillDkgRitualManager]'s, deliberately: the member who proposes + * a signature coordinates it, every protocol message travels as a gift-wrapped + * rumor on the kinds in [FrostSigningEvents], each inbound message is persisted + * and then the session is asked whether it can move, and every step is + * recomputed from stored inputs so a device killed mid-round resumes on the + * next message. What that manager's own notes say about being message-driven + * applies here unchanged. + * + * Three things are genuinely different, and each of them is why this is a + * separate manager rather than another branch of that one. + * + * ### It does not need everybody + * + * A DKG cannot finish until every member takes part; that is what makes the key. + * Signing with a t-of-n key needs t, and waiting for n would throw away the + * property the group ran a ceremony to get. So the coordinator waits for the + * threshold to be reachable, picks a signer set, and says who is in it. Members + * outside the set do nothing and are not stalling anything. + * + * ### Restart-safety is forced, not chosen + * + * `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 + * [FrostSigningSession] for the two rules that hold that in place; both are + * enforced here, in [acceptProposal] and in [record]. + * + * ### One approval, not three + * + * 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. + */ +object FrostSigningManager { + private const val TAG = "FrostSigningManager" + + private val logger = Logger.withTag(TAG) + + /** + * Opens a signing session, making this device the coordinator. + * + * [unsignedEvent] is signed as it stands apart from its `pubkey`, which is + * replaced with the group's key: a signature over an event claiming + * somebody else's author would verify against nothing. + */ + suspend fun proposeSigning( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + kind: Kind, + tags: Array>, + content: String, + createdAt: Long = Clock.System.now().epochSeconds + ): FrostSigningSession { + val key = completedKey(database, localChatRoom.chatRoom.id) + ?: throw IllegalStateException("This group has no shared key to sign with") + + val signerId = signerIdOf(database, key, userPublicKey) + ?: throw IllegalStateException("This device is not a participant in ceremony ${key.id}") + + val unsignedEvent = unsignedEventOf(key, kind, tags, content, createdAt) + val sessionId = RandomInstance.bytes(32).toHex() + + val session = FrostSigningSession( + id = sessionId, + chatRoomId = localChatRoom.chatRoom.id, + coordinatorPublicKey = userPublicKey, + userPublicKey = userPublicKey, + dkgSessionId = key.id, + threshold = key.threshold, + participantCount = key.participantCount, + signerId = signerId, + 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) + announceStarted(database, session) + + logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${key.id}") + + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.PROPOSAL, + content = session.unsignedEventJson, + includeKey = true + ) + + advance(database, localChatRoom, sessionId) + + return session + } + + /** + * Feeds one inbound signing message in and advances the session as far as it + * will go. Safe to call twice with the same message: every write is keyed + * and every step recomputed from stored inputs. + */ + suspend fun processSigningPayload( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + giftWrapPayload: GiftWrapPayload, + userPublicKey: HexKey + ) { + val sessionId = FrostSigningEvents.parseSessionId(giftWrapPayload.tags) + if (sessionId == null) { + logger.w("FROST payload ${giftWrapPayload.id} has no session tag; dropping") + return + } + + val session = if (giftWrapPayload.kind == FrostSigningEvents.PROPOSAL) { + acceptProposal( + database = database, + localChatRoom = localChatRoom, + giftWrapPayload = giftWrapPayload, + sessionId = sessionId, + userPublicKey = userPublicKey + ) + } else { + // Not knowing the session is normal: gift wraps carry a randomised + // created_at and relays hand them back in no particular order, so a + // nonce routinely lands before the proposal that asks for it. The + // payload is already stored, and [acceptProposal] replays it once the + // proposal turns up. + database.frostSigningSessionDao().getSessionById(sessionId) + } + + if (session == null) { + logger.i("No signing session $sessionId for kind ${giftWrapPayload.kind}; leaving it stored") + return + } + + if (session.stage == FrostSigningStage.FAILED) { + logger.i("Session $sessionId already failed; ignoring kind ${giftWrapPayload.kind}") + return + } + + if (!record(database, session, giftWrapPayload)) return + + advance(database, localChatRoom, session.id) + } + + /** + * Records a proposal. Returns the session, whether it was just created or + * already known. + * + * Publishes nothing: a signature is the one thing a group's key exists to + * produce, so a relay delivering an event to a phone in a pocket must not be + * enough to make it happen. + */ + private suspend fun acceptProposal( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + giftWrapPayload: GiftWrapPayload, + sessionId: String, + 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(giftWrapPayload.content) + if (proposed != null && proposed.id != existing.eventId) { + logger.w( + "Session $sessionId re-proposed with event ${proposed.id}, " + + "but it is already signing ${existing.eventId}; ignoring" + ) + } + return existing + } + + val dkgSessionId = FrostSigningEvents.parseKey(giftWrapPayload.tags) + if (dkgSessionId == null) { + logger.w("Signing proposal $sessionId names no key; dropping") + return null + } + + val key = database.dkgSessionDao().getSessionById(dkgSessionId) + if (key == null || key.stage != DkgRitualStage.COMPLETE) { + logger.w("Signing proposal $sessionId names key $dkgSessionId, which this device does not hold; dropping") + return null + } + + val signerId = signerIdOf(database, key, userPublicKey) + if (signerId == null) { + logger.w("Signing proposal $sessionId is for a ceremony this device did not take part in; dropping") + return null + } + + val proposed = Event.fromJsonOrNull(giftWrapPayload.content) + if (proposed == null) { + logger.w("Signing proposal $sessionId does not carry an event; dropping") + return null + } + + // Rebuilt from the event's own fields rather than trusted. The id is the + // 32 bytes every signer puts their share behind, so taking the proposer's + // word for it would let them have the group sign one thing while being + // shown another. + val unsignedEvent = unsignedEventOf( + key = key, + 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" + ) + return null + } + + val session = FrostSigningSession( + id = sessionId, + chatRoomId = localChatRoom.chatRoom.id, + // Whoever proposes coordinates. Aggregating nonces and partial + // signatures gives no power over the outcome -- a wrong aggregate + // produces a signature that does not verify, not a forged one. + coordinatorPublicKey = giftWrapPayload.publicKey, + userPublicKey = userPublicKey, + dkgSessionId = key.id, + threshold = key.threshold, + participantCount = key.participantCount, + signerId = signerId, + unsignedEventJson = unsignedEvent.toJson(), + eventId = unsignedEvent.id, + nonceRandom = RandomInstance.bytes(32).toHex() + ) + database.frostSigningSessionDao().upsert(session) + announceStarted(database, session) + + logger.i("Recorded signing session $sessionId over ${unsignedEvent.id}; awaiting approval") + + announceApprovalNeeded(database, session) + replayStoredMessages(database, session) + + return session + } + + /** + * Feeds in every message of this session that arrived before the proposal did. + * + * They were dropped at the time for want of a session to file them under, but + * the inbound path stores every payload it decrypts before dispatching on + * kind, so nothing was actually lost — this reads them back out. + */ + private suspend fun replayStoredMessages( + database: MantraDatabase, + session: FrostSigningSession + ) { + val stored = database.giftWrapPayloadDao().getByChatRoomAndKinds( + chatRoomId = session.chatRoomId, + kinds = FrostSigningEvents.ALL.toList() + ).filter { payload -> + payload.kind != FrostSigningEvents.PROPOSAL && + FrostSigningEvents.parseSessionId(payload.tags) == session.id + } + + if (stored.isEmpty()) return + + logger.i("Replaying ${stored.size} stored message(s) for signing session ${session.id}") + + stored.forEach { payload -> + if (!record(database, session, payload)) return + } + } + + /** + * Files one signing message. Returns false when the message ends the session, + * so the caller stops rather than trying to advance a dead one. + */ + private suspend fun record( + database: MantraDatabase, + session: FrostSigningSession, + giftWrapPayload: GiftWrapPayload + ): Boolean { + when (giftWrapPayload.kind) { + FrostSigningEvents.PROPOSAL -> Unit // handled by acceptProposal + + FrostSigningEvents.NONCE, + FrostSigningEvents.PARTIAL_SIGNATURE -> { + val known = database.frostSigningSessionDao() + .getMessage(session.id, giftWrapPayload.kind, giftWrapPayload.publicKey) != null + + database.frostSigningSessionDao().upsert( + FrostSignerMessage( + sessionId = session.id, + signerPublicKey = giftWrapPayload.publicKey, + kind = giftWrapPayload.kind, + payload = giftWrapPayload.content + ) + ) + + if (!known) { + announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + } + } + + FrostSigningEvents.SIGNER_SET -> { + if (!isFromCoordinator(session, giftWrapPayload)) return true + + val signerIds = FrostSigningEvents.parseSignerIds(giftWrapPayload.tags) + if (signerIds == null) { + logger.w("Session ${session.id}: signer set carries no ids; ignoring") + 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 = giftWrapPayload.content, + signerIds = signerIds.joinToString(",") + ) + } else { + current + } + } + + if (!known) { + announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + } + } + + FrostSigningEvents.SIGNATURE -> { + if (!isFromCoordinator(session, giftWrapPayload)) return true + + val known = current(database, session).signature != null + + update(database, session) { current -> + if (current.signature == null) { + current.copy(signature = giftWrapPayload.content) + } else { + current + } + } + + if (!known) { + announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey) + } + } + + FrostSigningEvents.FAILURE -> { + fail( + database = database, + session = session, + reason = "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}", + culprit = giftWrapPayload.publicKey + ) + return false + } + } + + return true + } + + private fun isFromCoordinator( + session: FrostSigningSession, + giftWrapPayload: GiftWrapPayload + ): Boolean { + if (giftWrapPayload.publicKey == session.coordinatorPublicKey) return true + + logger.w( + "Session ${session.id}: kind ${giftWrapPayload.kind} from " + + "${giftWrapPayload.publicKey.take(8)}, who is not the coordinator; ignoring" + ) + return false + } + + /** + * Takes every step the stored messages now allow, in order, stopping at the + * first one still waiting on somebody. + * + * Each step asks whether its output already exists rather than whether the + * stage says it has run, so re-entering after a crash — or after the same + * message is delivered twice — repeats no work and skips none. + */ + private suspend fun advance( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + sessionId: String + ) { + var session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) return + + val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return + val secretShare = key.secretShare?.let { PrivateKey(ByteVector32(it)) } ?: return + val thresholdPublicKey = key.thresholdPublicKey?.let { PublicKey(ByteVector(it.hexToByteArray())) } + ?: return + + try { + // Nothing of this device's own goes out before its owner has said so. + // Returns rather than throws: the session is not failing, it is waiting + // on a person, and everything received stays stored so it resumes the + // moment they approve. + if (session.signApprovedAt == null) { + announceApprovalNeeded(database, session) + return + } + + val tweakCache = TweakCache.create(thresholdPublicKey) + 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 + ) + + if (ownMessage(database, session, FrostSigningEvents.NONCE) == null) { + publishOwn( + database, + localChatRoom, + session, + FrostSigningEvents.NONCE, + publicNonce.data.toHex() + ) + } + + if (session.isCoordinator() && session.aggregatedNonce == null) { + val offered = orderedNonces(database, session) ?: return + val chosen = offered.take(session.threshold) + + 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() } + ) + } + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.SIGNER_SET, + content = aggregated.toByteArray().toHex(), + signerIds = chosen.map { (id, _) -> id } + ) + announceStep( + database, + session, + FrostSigningEvents.SIGNER_SET, + session.userPublicKey + ) + } + + val aggregatedNonce = session.aggregatedNonce ?: return + val signerIds = session.signerIdList() ?: return + 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") + + publishOwn( + database, + localChatRoom, + session, + FrostSigningEvents.PARTIAL_SIGNATURE, + partialSignature.toHex() + ) + } + + if (session.isCoordinator() && session.signature == null) { + val partials = orderedPartialSignatures(database, session, signerIds) ?: return + + val signature = signingSession + .aggregateSigs(partials.map { ByteVector32(it) }) + .orThrow("aggregating partial signatures") + .toHex() + + session = update(database, session) { it.copy(signature = signature) } + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.SIGNATURE, + content = signature + ) + announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey) + } + + val signature = session.signature ?: return + + // The payoff: a signature that verifies is one the group made, whoever + // relayed it. Checking rather than trusting is what keeps a faulty or + // dishonest coordinator from passing off something that will be + // rejected by every relay it reaches. + 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}") + } + + update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) } + announce( + database = database, + session = session, + messageType = ChatMessage.TYPE_FROST_COMPLETE, + content = "The group signed the event. It took ${session.threshold} of " + + "${session.participantCount} members.", + actor = session.coordinatorPublicKey + ) + + logger.i("Signing session $sessionId complete") + } catch (e: CancellationException) { + // The sync was torn down mid-step, which says nothing about the session. + throw e + } catch (e: Throwable) { + logger.e("Signing session $sessionId failed", e) + fail(database, session, e.message ?: e::class.simpleName ?: "Unknown error") + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.FAILURE, + content = e.message ?: "Signing failed" + ) + } + } + + /** + * The event this session produces, with the signature on it. + * + * 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) + + return Event( + id = unsigned.id, + pubKey = unsigned.pubKey, + createdAt = unsigned.createdAt, + kind = unsigned.kind, + tags = unsigned.tags, + content = unsigned.content, + sig = signature + ) + } + + /** The finished event, or null while the session is still running. */ + fun signedEvent(session: FrostSigningSession): Event? = + session.signature?.let { signedEvent(session, it) } + + /** + * The nonces on offer, as (signer id, nonce), ordered by signer id — or null + * while fewer than the threshold have arrived. + * + * Ordered so that every device that recomputes the aggregate from the same + * set arrives at the same bytes. FROST binds the signer set into the + * challenge, so an order nobody else derives is a signature nobody can + * aggregate. + */ + private suspend fun orderedNonces( + database: MantraDatabase, + session: FrostSigningSession + ): 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()) + } + } + .sortedBy { (id, _) -> id } + + if (offered.size < session.threshold) { + logger.d("Session ${session.id}: ${offered.size}/${session.threshold} nonces") + return null + } + + return offered + } + + /** + * The chosen signers' partial signatures in signer-set order, or null while + * any are missing. Aggregation needs exactly one per signer, positionally + * matched to the set the session was created with. + */ + private suspend fun orderedPartialSignatures( + database: MantraDatabase, + session: FrostSigningSession, + signerIds: List + ): List? { + val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null + val memberById = signerIds(database, key).entries.associate { (member, id) -> id to member } + + val payloadByMember = database.frostSigningSessionDao() + .getMessagesByKind(session.id, FrostSigningEvents.PARTIAL_SIGNATURE) + .associate { it.signerPublicKey to it.payload } + + val partials = signerIds.mapNotNull { id -> + memberById[id]?.let { payloadByMember[it] } + } + + if (partials.size < signerIds.size) { + logger.d("Session ${session.id}: ${partials.size}/${signerIds.size} partial signatures") + return null + } + + return partials.map { it.hexToByteArray() } + } + + /** + * Every ceremony participant's FROST id, keyed by their nostr public key. + * + * The id is the member's index in the ceremony's participant order, which is + * the bytewise sort of the host public keys — the same ordering ChillDKG + * hashed into the session identity, and so the same one the public shares + * are in. Deriving it rather than storing it means signing cannot disagree + * with the ceremony that made the key. + */ + private suspend fun signerIds( + database: MantraDatabase, + key: DkgSession + ): Map { + val hostKeys = database.dkgSessionDao() + .getMessagesByKind(key.id, DkgRitualEvents.HOST_KEY) + .associate { it.participantPublicKey to it.payload.lowercase() } + + val order = hostKeys.values.sorted() + + return hostKeys.mapNotNull { (member, hostKey) -> + order.indexOf(hostKey).takeIf { it >= 0 }?.let { member to it } + }.toMap() + } + + private suspend fun signerIdOf( + database: MantraDatabase, + key: DkgSession, + member: HexKey + ): Int? = signerIds(database, key)[member] + + /** + * The group's usable key, or null when it has none. + * + * A group can have run more than one ceremony; the newest completed one is + * the live key, matching what the shared-key screen shows. + */ + suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? = + database.dkgSessionDao() + .getLatestSessionForChatRoom(chatRoomId) + ?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null } + + /** Whether this group can sign at all, read by the UI so it offers nothing that would fail. */ + suspend fun canSign(database: MantraDatabase, chatRoomId: String): Boolean = + completedKey(database, chatRoomId) != null + + /** + * The unsigned event a session signs: the caller's fields under the group's + * key, with the id computed from them. + */ + private fun unsignedEventOf( + key: DkgSession, + kind: Kind, + tags: Array>, + content: String, + createdAt: Long + ): Event { + // Nostr identifies an author by the x-only key a BIP-340 signature + // verifies against, which for a FROST key is the tweaked threshold key + // rather than the 33-byte value the ceremony reports. + val thresholdPublicKey = PublicKey(ByteVector(key.thresholdPublicKey!!.hexToByteArray())) + val groupPubKey = TweakCache.create(thresholdPublicKey).tweakedPublicKey.value.toHex() + + return Event( + id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content + ), + pubKey = groupPubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "" + ) + } + + /** + * Turns a failed FROST step into an exception so it joins [advance]'s single + * failure path. + * + * The library reports these as `Either`, because a partial signature that + * will not aggregate is a normal outcome of collecting them from other + * people rather than a bug. This session has one response to all of them — + * there is no signature, so it dies and the group is told — and what is + * worth keeping is which step failed. + */ + private fun Either.orThrow(step: String): T = when (this) { + is Either.Right -> value + is Either.Left -> throw IllegalStateException("FROST $step failed: ${value.message}", value) + } + + /** This device's own message of [kind], if it has published one. */ + private suspend fun ownMessage( + database: MantraDatabase, + session: FrostSigningSession, + kind: Kind + ): FrostSignerMessage? = database.frostSigningSessionDao().getMessage( + sessionId = session.id, + kind = kind, + signerPublicKey = session.userPublicKey + ) + + /** + * Applies [transform] to the *stored* session and returns what was written. + * + * Callers hold a session across several steps, each of which may write; going + * back to the row means a later write cannot silently undo an earlier one by + * copying from a stale snapshot. + */ + private suspend fun update( + database: MantraDatabase, + session: FrostSigningSession, + transform: (FrostSigningSession) -> FrostSigningSession + ): FrostSigningSession { + val current = database.frostSigningSessionDao().getSessionById(session.id) ?: session + val updated = transform(current) + + if (updated == current) return current + + val stamped = updated.copy(updatedAt = Clock.System.now()) + database.frostSigningSessionDao().upsert(stamped) + + return stamped + } + + /** + * Moves the progress label forward, never back. Stages are declared in the + * order the session runs them, so a message arriving out of order cannot + * walk the UI back down the ladder. + */ + private suspend fun moveTo( + database: MantraDatabase, + session: FrostSigningSession, + stage: FrostSigningStage + ): FrostSigningSession = update(database, session) { current -> + val settled = + current.stage == FrostSigningStage.COMPLETE || current.stage == FrostSigningStage.FAILED + + if (settled || current.stage.ordinal >= stage.ordinal) current else current.copy(stage = stage) + } + + private suspend fun fail( + database: MantraDatabase, + session: FrostSigningSession, + reason: String, + culprit: HexKey = session.userPublicKey + ) { + // Read before writing so the notice goes out once. A session can be failed + // from two directions -- a FAILURE message from a member, and a fault + // raised locally -- and the group does not need to be told twice. + val current = database.frostSigningSessionDao().getSessionById(session.id) ?: session + if (current.stage == FrostSigningStage.FAILED) return + + update(database, current) { + it.copy(stage = FrostSigningStage.FAILED, failureReason = reason) + } + announce( + database = database, + session = current, + messageType = ChatMessage.TYPE_FROST_FAILED, + content = "abandoned the signature. Nothing was signed, and it is safe to ask again.", + actor = culprit + ) + } + + /** The stored session, for reading a column back before overwriting it. */ + private suspend fun current( + database: MantraDatabase, + session: FrostSigningSession + ): FrostSigningSession = + database.frostSigningSessionDao().getSessionById(session.id) ?: session + + /** + * Whether this session is waiting on its owner to agree to sign. + * + * Mirrors the gate in [advance]: pending exactly when [advance] would stop at + * it. The two must agree, or the screen offers an approval that does nothing, + * or none while the session sits still. + */ + fun isAwaitingApproval(session: FrostSigningSession): Boolean { + if (session.stage == FrostSigningStage.COMPLETE || session.stage == FrostSigningStage.FAILED) { + return false + } + + return session.signApprovedAt == null + } + + /** + * Records that this device's owner agreed to sign, then lets the session run + * as far as the next thing it is waiting on. + * + * Approving a session not waiting on it is a no-op rather than an error: a + * stale screen left open across a redelivery should not publish anything. + */ + suspend fun approve( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + sessionId: String + ) { + val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + + if (!isAwaitingApproval(session)) { + logger.i("Session $sessionId is not waiting on an approval; ignoring it") + return + } + + update(database, session) { it.copy(signApprovedAt = Clock.System.now()) } + + logger.i("Session $sessionId: signing approved") + + advance(database, localChatRoom, sessionId) + } + + /** + * Refuses to sign, and tells the group so the coordinator can pick somebody + * else rather than wait. + * + * A t-of-n group can sign without this member, so declining is a normal + * outcome and not a failure of the session — but only if it is said out loud. + * Silence is indistinguishable from a phone in a pocket. + */ + suspend fun decline( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + sessionId: String + ) { + val session = database.frostSigningSessionDao().getSessionById(sessionId) ?: return + + if (!isAwaitingApproval(session)) return + + fail( + database = database, + session = session, + reason = "Declined on this device", + culprit = session.userPublicKey + ) + broadcast( + database = database, + localChatRoom = localChatRoom, + session = session, + kind = FrostSigningEvents.FAILURE, + content = "Declined to sign" + ) + } + + private suspend fun announceStarted(database: MantraDatabase, session: FrostSigningSession) = + 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.", + actor = session.coordinatorPublicKey + ) + + /** + * Tells the group's chat that this session is waiting on the reader. + * + * Written once, guarded by [FrostSigningSession.approvalRequestedAt], because + * [advance] runs on every arriving message and would otherwise ask again on + * each one. + */ + private suspend fun announceApprovalNeeded( + database: MantraDatabase, + session: FrostSigningSession + ) { + if (current(database, session).approvalRequestedAt != null) return + + update(database, session) { it.copy(approvalRequestedAt = Clock.System.now()) } + + 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.", + actor = session.userPublicKey + ) + } + + /** + * Puts one protocol message in the group's chat, as a line naming who sent it. + * + * Same reasoning as the ceremony's transcript: these are things a particular + * member's device did, and a signature made with the group's key is worth + * being able to watch. The wording describes what a step accomplishes rather + * than what it is called. + */ + private suspend fun announceStep( + database: MantraDatabase, + session: FrostSigningSession, + kind: Kind, + actor: HexKey + ) { + val (messageType, content) = when (kind) { + FrostSigningEvents.NONCE -> ChatMessage.TYPE_FROST_NONCE to + "offered to help sign, sending the one-time 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." + + FrostSigningEvents.SIGNATURE -> ChatMessage.TYPE_FROST_SIGNATURE to + "combined the parts into the group's signature." + + // PROPOSAL and FAILURE are announced by the code that acts on them -- + // both say more than the message itself carries. + else -> return + } + + announce( + database = database, + session = session, + messageType = messageType, + content = content, + actor = actor + ) + } + + /** + * Puts a signing milestone in the group's chat. + * + * Each device writes its own row from the messages it has already received, so + * this costs no traffic and cannot disagree with the session it describes. + * Written once by construction rather than de-duplication: every caller checks + * before it writes, because [ChatMessage] has no key to make a second insert a + * no-op. + */ + private suspend fun announce( + database: MantraDatabase, + session: FrostSigningSession, + messageType: String, + content: String, + actor: HexKey + ) { + database.chatMessageDao().upsert( + ChatMessage( + senderPublicKey = actor, + isUserMessage = actor == session.userPublicKey, + giftWrapPayloadId = null, + marmotGroupEventId = null, + marmotInnerEventId = null, + chatRoomId = session.chatRoomId, + content = content, + messageType = messageType + ) + ) + } + + /** + * Broadcasts one of this device's own protocol messages AND records it + * locally. The local copy matters: the coordinator signs too, and its own + * message has to be in the aggregation alongside everyone else's. + * + * Queued before it is recorded, because [advance] republishes any message it + * has no local copy of. A crash between the two therefore costs a duplicate + * broadcast — which every receiver folds away — rather than a message the + * group waits on forever. + */ + private suspend fun publishOwn( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + session: FrostSigningSession, + kind: Kind, + content: String + ) { + broadcast(database, localChatRoom, session, kind, content) + + val known = ownMessage(database, session, kind) != null + + database.frostSigningSessionDao().upsert( + FrostSignerMessage( + sessionId = session.id, + signerPublicKey = session.userPublicKey, + kind = kind, + payload = content + ) + ) + + if (!known) announceStep(database, session, kind, session.userPublicKey) + } + + /** + * Queues a signing message as a gift-wrap payload. `NotaryViewModel` picks it + * up, seals a copy per participant and broadcasts — the same path chat + * messages take, which is why this needs no transport of its own. + */ + private suspend fun broadcast( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + session: FrostSigningSession, + kind: Kind, + content: String, + includeKey: Boolean = false, + signerIds: List? = null + ) { + val receiverTags = localChatRoom.localParticipants + .distinctBy { it.participant.participantPublicKey } + .filter { it.participant.participantPublicKey != session.userPublicKey } + .map { localParticipant -> + PTag.assemble( + localParticipant.participant.participantPublicKey, + localParticipant.participant.relayHint?.let { NormalizedRelayUrl(it) } + ) + } + + val tags = receiverTags.toTypedArray() + FrostSigningEvents.assembleTags( + sessionId = session.id, + dkgSessionId = if (includeKey) session.dkgSessionId else null, + signerIds = signerIds + ) + + val createdAt = Clock.System.now().epochSeconds + val giftWrapPayloadId = EventHasher.hashId( + pubKey = session.userPublicKey, + createdAt = createdAt, + tags = tags, + content = content, + kind = kind + ) + + database.giftWrapPayloadDao().upsert( + GiftWrapPayload( + id = giftWrapPayloadId, + kind = kind, + tags = tags, + createdAt = Instant.fromEpochSeconds(createdAt), + content = content, + chatRoomId = localChatRoom.chatRoom.id, + publicKey = session.userPublicKey + ) + ) + } +} 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 new file mode 100644 index 00000000..29ea90ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -0,0 +1,99 @@ +package press.mantra.compose.nostr.frost + +import com.vitorpamplona.quartz.nip01Core.core.Kind +import press.mantra.compose.nostr.frost.tags.FrostKeyTag +import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag +import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag + +/** + * The nostr kinds a FROST signing session is carried on. + * + * Like the ChillDKG kinds these are **rumor** kinds: they only ever exist + * inside a NIP-17 gift wrap addressed to the group, so no relay sees them + * unencrypted and the replaceable semantics normally implied by the 3xxxx range + * never apply. + * + * Who talks to whom, in order: + * + * ``` + * proposer --[ 30320 proposal ]-> everyone the unsigned event to sign + * signer --[ 30321 nonce ]-> everyone this device's public nonce + * proposer --[ 30322 signer set ]-> everyone who is signing, and their aggregated nonce + * signer --[ 30323 partial ]-> everyone this device's partial signature + * proposer --[ 30324 signature ]-> everyone the finished 64-byte signature + * anyone --[ 30325 failure ]-> everyone abandon + blame + * ``` + * + * ### Why 3032x and not 3031x + * + * The DKG kinds run 30310-30316 and the nip30303 document kinds run 30300 + * upwards; those two have already met at 30310 and 30311, and + * [press.mantra.compose.nostr.nip30303.SubmissionEvent] sits on 30312, which is + * also the DKG's round-1 kind. They are kept apart today only by travelling on + * different transports -- documents inside Marmot group events, rituals inside + * NIP-17 wraps -- which is luck rather than design. + * + * Signing runs on the same transport as the DKG and in the same rooms, so it + * starts at 30320 with a deliberate gap. Anything added to either family has + * room to grow without a second accident. + */ +object FrostSigningEvents { + /** + * Opens a session. Content is the unsigned nostr event, as JSON; the key to + * sign with is named in [FrostKeyTag]. + */ + val PROPOSAL: Kind = 30320 + + /** A signer's `IndividualNonce`, hex encoded. */ + val NONCE: Kind = 30321 + + /** + * The coordinator's chosen signers, in [FrostSignerIdsTag], with their + * `AggregatedNonce` as the content, hex encoded. + */ + val SIGNER_SET: Kind = 30322 + + /** A signer's 32-byte partial signature, hex encoded. */ + val PARTIAL_SIGNATURE: Kind = 30323 + + /** The finished 64-byte BIP-340 signature over the event id, hex encoded. */ + val SIGNATURE: Kind = 30324 + + /** Session abandoned. Content is the reason, for showing to the group. */ + val FAILURE: Kind = 30325 + + /** Every kind above, for filtering inbound payloads in one check. */ + val ALL: Set = setOf( + PROPOSAL, + NONCE, + SIGNER_SET, + PARTIAL_SIGNATURE, + SIGNATURE, + FAILURE + ) + + fun isFrostSigningKind(kind: Kind): Boolean = kind in ALL + + /** + * Tags for a signing message. The session id is on every kind so a message + * from an abandoned attempt can be dropped rather than mixed in. + */ + fun assembleTags( + sessionId: String, + dkgSessionId: String? = null, + signerIds: List? = null + ): Array> = buildList { + add(FrostSessionIdTag.assemble(sessionId)) + dkgSessionId?.let { add(FrostKeyTag.assemble(it)) } + signerIds?.let { add(FrostSignerIdsTag.assemble(it)) } + }.toTypedArray() + + fun parseSessionId(tags: Array>): String? = + tags.firstNotNullOfOrNull(FrostSessionIdTag::parse)?.sessionId + + fun parseKey(tags: Array>): String? = + tags.firstNotNullOfOrNull(FrostKeyTag::parse)?.dkgSessionId + + fun parseSignerIds(tags: Array>): List? = + tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostKeyTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostKeyTag.kt new file mode 100644 index 00000000..4f777718 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostKeyTag.kt @@ -0,0 +1,36 @@ +package press.mantra.compose.nostr.frost.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Which key the group is being asked to sign with, named by the ceremony that + * created it. + * + * A group can hold more than one shared key -- a ceremony is re-runnable, and + * a member leaving is a reason to run another -- and a signer holds a + * different secret share under each. Signing with the share from the wrong + * ceremony produces a partial signature that cannot aggregate, so the session + * says which one from the start rather than leaving each device to guess at + * its most recent. + */ +class FrostKeyTag( + val dkgSessionId: String, +) { + fun toTagArray() = assemble(dkgSessionId = dkgSessionId) + + companion object { + const val TAG_NAME = "frost_key" + + fun parse(tag: Array): FrostKeyTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + return FrostKeyTag(dkgSessionId = tag[1]) + } + + fun assemble(dkgSessionId: String): Array = arrayOf(TAG_NAME, dkgSessionId) + + fun assemble(frostKeyTag: FrostKeyTag) = assemble(dkgSessionId = frostKeyTag.dkgSessionId) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSessionIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSessionIdTag.kt new file mode 100644 index 00000000..91222d44 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSessionIdTag.kt @@ -0,0 +1,36 @@ +package press.mantra.compose.nostr.frost.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Binds a signing message to one session. + * + * A group signs more than once with the same key, and an abandoned attempt at + * signing one event must never have its messages fed into a live attempt at + * another. That is stricter here than it is for a DKG: every signer's secret + * nonce is derived per session, so two sessions sharing an id would be two + * different messages signed under one nonce -- which is how a secret share + * leaks. + */ +class FrostSessionIdTag( + val sessionId: String, +) { + fun toTagArray() = assemble(sessionId = sessionId) + + companion object { + const val TAG_NAME = "frost_session" + + fun parse(tag: Array): FrostSessionIdTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + return FrostSessionIdTag(sessionId = tag[1]) + } + + fun assemble(sessionId: String): Array = arrayOf(TAG_NAME, sessionId) + + fun assemble(frostSessionIdTag: FrostSessionIdTag) = + assemble(sessionId = frostSessionIdTag.sessionId) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSignerIdsTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSignerIdsTag.kt new file mode 100644 index 00000000..06af36ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/tags/FrostSignerIdsTag.kt @@ -0,0 +1,42 @@ +package press.mantra.compose.nostr.frost.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The participants the coordinator chose to sign with, as their FROST ids, in + * the order their nonces were aggregated. + * + * A t-of-n key does not need everyone, so somebody has to pick which t, and + * only the coordinator sees every nonce. Every signer then has to build the + * same session from the same set in the same order -- FROST binds the set into + * the challenge, so a device that disagrees about who is signing produces a + * partial signature that will not aggregate. + */ +class FrostSignerIdsTag( + val signerIds: List, +) { + fun toTagArray() = assemble(signerIds = signerIds) + + companion object { + const val TAG_NAME = "frost_signers" + + fun parse(tag: Array): FrostSignerIdsTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + + // Order is meaningful, so a single unparseable entry invalidates the + // whole list rather than quietly shortening it. + val ids = tag[1].split(",").map { it.trim().toIntOrNull() ?: return null } + if (ids.isEmpty()) return null + + return FrostSignerIdsTag(signerIds = ids) + } + + fun assemble(signerIds: List): Array = + arrayOf(TAG_NAME, signerIds.joinToString(",")) + + fun assemble(frostSignerIdsTag: FrostSignerIdsTag) = + assemble(signerIds = frostSignerIdsTag.signerIds) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt new file mode 100644 index 00000000..0d4fc4c5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt @@ -0,0 +1,278 @@ +package press.mantra.compose.managers + +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import fr.acinq.bitcoin.crypto.frost.TweakCache +import fr.acinq.secp256k1.Hex +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.frost.FrostSigningEvents + +/** + * The two rounds a signing session runs, against real FROST. + * + * `FrostSigningManager` spreads these steps across arriving messages, several + * devices and a database, none of which a unit test can stand up. What it can + * do is run the same calls in the same order with the same arguments and check + * that what comes out is a signature nostr will accept — which is the part + * that was written from reading the library rather than from a working example, + * and so the part most likely to be subtly wrong. + * + * A signature that verifies is the whole contract: if these calls are wired up + * incorrectly the aggregate simply fails to verify, silently, on every device. + */ +class FrostSigningRoundTest { + private val participants = 3 + private val threshold = 2 + + /** Stands in for a completed ceremony. A trusted dealer is fine here: the test is about signing. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val tweakCache: TweakCache = TweakCache.create(keyMaterial.thresholdPublicKey) + + /** The group's nostr identity: the x-only key a BIP-340 signature verifies against. */ + private val groupPubKey = tweakCache.tweakedPublicKey.value.toHex() + + /** The 32 bytes actually signed — a nostr event id, exactly as the manager computes it. */ + private fun eventId(content: String): String = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = 1_700_000_000L, + kind = 1, + tags = arrayOf(), + content = content + ) + + /** + * One signer's half of the protocol, in the manager's order: regenerate the + * nonce from stored randomness, then sign once the set is known. + */ + private fun nonceOf(signerId: Int, message: ByteVector, random: String): Pair = + SecretNonce.generate( + sessionRandom = ByteVector32(random), + secretShare = keyMaterial.secretShares[signerId], + publicShare = keyMaterial.publicShares[signerId], + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = message, + extraInput = null + ) + + private fun sessionFor(signerIds: List, nonces: List, message: ByteVector): Session { + val aggregated = IndividualNonce.aggregate(nonces).right!! + + return Session.create( + aggregatedNonce = aggregated, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { keyMaterial.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = tweakCache, + message = message + ) + } + + @Test + fun `a threshold of signers produces a signature nostr accepts`() { + val id = eventId("the group agrees") + val message = ByteVector(id.hexToByteArray()) + + // Two of the three sign, which is the point of a 2-of-3 key. + val signerIds = listOf(0, 1) + val nonces = signerIds.map { nonceOf(it, message, "a".repeat(63) + "${it + 1}") } + + val session = sessionFor(signerIds, nonces.map { it.second }, message) + + val partials = signerIds.mapIndexed { position, signerId -> + session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!! + } + + val signature = session.aggregateSigs(partials).right!! + + assertTrue( + Nip01Crypto.verify( + signature = signature.toByteArray(), + hash = id.hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ), + "the aggregated signature must verify against the group's x-only key" + ) + } + + @Test + fun `a different pair of signers signs the same event just as well`() { + val id = eventId("the group agrees") + val message = ByteVector(id.hexToByteArray()) + + // Whoever happens to be available. The coordinator picks; the signature + // that comes out must not depend on which t it picked. + val signerIds = listOf(1, 2) + val nonces = signerIds.map { nonceOf(it, message, "b".repeat(63) + "${it + 1}") } + + val session = sessionFor(signerIds, nonces.map { it.second }, message) + val partials = signerIds.mapIndexed { position, signerId -> + session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!! + } + + val signature = session.aggregateSigs(partials).right!! + + assertTrue( + Nip01Crypto.verify( + signature = signature.toByteArray(), + hash = id.hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ) + ) + } + + @Test + fun `a signature over one event does not verify against another`() { + val id = eventId("the group agrees") + val message = ByteVector(id.hexToByteArray()) + + val signerIds = listOf(0, 1) + val nonces = signerIds.map { nonceOf(it, message, "c".repeat(63) + "${it + 1}") } + val session = sessionFor(signerIds, nonces.map { it.second }, message) + val partials = signerIds.mapIndexed { position, signerId -> + session.sign(nonces[position].first, keyMaterial.secretShares[signerId], signerId.toUInt()).right!! + } + val signature = session.aggregateSigs(partials).right!! + + assertFalse( + Nip01Crypto.verify( + signature = signature.toByteArray(), + hash = eventId("the group agrees to something else").hexToByteArray(), + pubKey = groupPubKey.hexToByteArray() + ), + "a signature is over one event id and must not carry to another" + ) + } + + @Test + fun `regenerating a nonce from the same seed and message gives the same nonce`() { + val id = eventId("the group agrees") + val message = ByteVector(id.hexToByteArray()) + val random = "d".repeat(63) + "1" + + // What makes a signing session restart-safe: SecretNonce cannot be stored, + // so the manager keeps its seed and derives again. If that were not + // reproducible a device that restarted mid-session would publish a partial + // signature against a nonce nobody aggregated. + val first = nonceOf(0, message, random).second + val second = nonceOf(0, message, random).second + + assertEquals(first.data.toHex(), second.data.toHex()) + } + + @Test + fun `the same seed under a different message gives a different nonce`() { + val random = "e".repeat(63) + "1" + + // The safety property behind reusing the seed at all: one session signs one + // message. Were the nonce independent of the message, a session that could + // be re-pointed at another event would sign twice under one nonce, which + // hands over the secret share. + val first = nonceOf(0, ByteVector(eventId("one thing").hexToByteArray()), random).second + val second = nonceOf(0, ByteVector(eventId("another thing").hexToByteArray()), random).second + + assertFalse(first.data.toHex() == second.data.toHex()) + } +} + +/** + * The pure bits of a signing session's bookkeeping: who is signing, and with + * which key. + */ +class FrostSigningSessionTest { + private fun session(signerId: Int, signerIds: String?) = FrostSigningSession( + id = "s".repeat(64), + chatRoomId = "room", + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + dkgSessionId = "k".repeat(64), + threshold = 2, + participantCount = 3, + signerId = signerId, + unsignedEventJson = "{}", + eventId = "e".repeat(64), + nonceRandom = "f".repeat(64), + signerIds = signerIds + ) + + @Test + fun `a member left out of the signer set is not a signer`() { + assertTrue(session(signerId = 1, signerIds = "0,1").isSigner()) + assertFalse(session(signerId = 2, signerIds = "0,1").isSigner()) + } + + @Test + fun `nobody is a signer until the coordinator has chosen`() { + assertFalse(session(signerId = 0, signerIds = null).isSigner()) + } + + @Test + fun `the signer set keeps the order it was aggregated in`() { + // FROST binds the set into the challenge, so this list is not a set of ids + // but a sequence positionally matched to the aggregated nonce. + assertEquals(listOf(2, 0, 1), session(signerId = 0, signerIds = "2,0,1").signerIdList()) + } + + @Test + fun `a signer set tag survives the trip through a tag array`() { + val tags = FrostSigningEvents.assembleTags( + sessionId = "session", + dkgSessionId = "ceremony", + signerIds = listOf(2, 0, 1) + ) + + assertEquals("session", FrostSigningEvents.parseSessionId(tags)) + assertEquals("ceremony", FrostSigningEvents.parseKey(tags)) + assertEquals(listOf(2, 0, 1), FrostSigningEvents.parseSignerIds(tags)) + } + + @Test + fun `a ceremony that recorded no public shares reads back null rather than empty`() { + // Ceremonies completed before the column existed. Signing falls back to not + // cross-checking shares, which the FROST API allows, rather than refusing. + val ceremony = DkgSession( + id = "k".repeat(64), + chatRoomId = "room", + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + threshold = 2, + participantCount = 3, + hostPublicKey = "h".repeat(66), + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64) + ) + + assertEquals(null, ceremony.publicShareList()) + assertEquals( + 2, + ceremony.copy( + publicShares = listOf( + Hex.encode(ByteArray(33) { 2 }), + Hex.encode(ByteArray(33) { 3 }) + ).joinToString(",") + ).publicShareList()?.size + ) + } +}