From dac8ed47c6985061470c6856acdf3c5c14da6607 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 20:15:29 -0400 Subject: [PATCH 1/3] feat(payments): persist payment requests for offline Store each request's PaymentRequestMetadata in a new paymentRequest table (schemaVersion 62 -> 63) so a chat's request cards and details draw offline and after a restart. The DB upsert applies the contract's merge rule in one statement: greater version wins, and at an equal version EXPIRED replaces OPEN. PaymentRequests writes through on ingest and applied updates, and hydrates stored copies ahead of the network read. --- .../PaymentRequest/PaymentRequests.swift | 56 +++++- .../Core/Session/SessionAuthenticator.swift | 3 +- .../GroupPaymentRequest+Storage.swift | 101 ++++++++++ .../Database+PaymentRequests.swift | 73 +++++++ .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 34 ++++ .../PaymentRequestsPersistenceTests.swift | 183 ++++++++++++++++++ 7 files changed, 447 insertions(+), 5 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Storage.swift create mode 100644 FlipcashCore/Sources/FlipcashStore/Database+PaymentRequests.swift create mode 100644 FlipcashTests/PaymentRequest/PaymentRequestsPersistenceTests.swift diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 6db6dea94..98ddccc47 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -8,6 +8,7 @@ import Foundation import Observation import FlipcashCore +import FlipcashStore nonisolated private let logger = Logger(label: "flipcash.payment-requests") @@ -18,6 +19,11 @@ nonisolated private let logger = Logger(label: "flipcash.payment-requests") /// ``ingest(_:)``, which keeps the greater version and, at an equal version, EXPIRED over OPEN; live /// updates go through ``apply(_:)``, which follows the same version rule. Updates are best effort, so /// the polls stay as reconciliation. +/// +/// Every copy that lands is also written to the database, which applies the same merge rule, and a +/// chat's or a request's stored copies are read back ahead of the network read so cards and details +/// draw offline and after a restart. Writes and reads are best effort: a failed one is logged and the +/// network path carries on. `unavailable`, the seals and the retry sets are session state and are not stored. @MainActor @Observable final class PaymentRequests { @@ -62,6 +68,7 @@ final class PaymentRequests { @ObservationIgnored private let closeRequest: Close @ObservationIgnored private let markPaidShare: MarkPaid @ObservationIgnored private let getPage: GetPage + @ObservationIgnored private let database: Database? @ObservationIgnored private var inFlight: Set = [] @ObservationIgnored private var sealReads: Set = [] /// Requests whose last read failed short of a server answer, retried on the next chat refetch. @@ -76,8 +83,10 @@ final class PaymentRequests { create: @escaping Create = { _, _, _ in throw Unconfigured() }, close: @escaping Close = { _ in throw Unconfigured() }, markPaid: @escaping MarkPaid = { _, _ in throw Unconfigured() }, - getPage: @escaping GetPage = { _, _, _, _ in throw Unconfigured() } + getPage: @escaping GetPage = { _, _, _, _ in throw Unconfigured() }, + database: Database? = nil ) { + self.database = database self.getChatPage = getChatPage self.getRequest = getRequest self.openingSeal = openingSeal @@ -149,8 +158,47 @@ final class PaymentRequests { GroupPaymentRequest.deckOrder(requests.values.filter { $0.chatID == conversationID }, viewer: viewer, at: now) } - /// Merges fetched copies into the store. + /// Merges fetched copies into the store and writes them through to the database. func ingest(_ incoming: [GroupPaymentRequest]) { + merge(incoming) + unavailable.subtract(incoming.map(\.id)) + persist(incoming) + } + + /// Reads `conversationID`'s stored requests into the store, ahead of the network read. + func hydrate(_ conversationID: ConversationID) async { + guard let database else { return } + do { + let stored = try await Task.detached { try database.paymentRequests(in: conversationID) }.value + merge(stored) + } catch { + logger.warning("Failed to read a chat's stored payment requests", metadata: ["error": "\(error)"]) + } + } + + /// Reads the stored copy of `id` into the store, ahead of the network read. + private func hydrate(_ id: PaymentRequestID) async { + guard let database else { return } + do { + let stored = try await Task.detached { try database.paymentRequest(id: id) }.value + if let stored { merge([stored]) } + } catch { + logger.warning("Failed to read a stored payment request", metadata: ["error": "\(error)"]) + } + } + + /// Writes `requests` to the database, which keeps the greater version per request. A failure + /// leaves the in-memory copy in place and never fails the fetch it came from. + private func persist(_ requests: [GroupPaymentRequest]) { + guard let database, !requests.isEmpty else { return } + do { + try database.upsertPaymentRequests(requests) + } catch { + logger.warning("Failed to store payment requests", metadata: ["error": "\(error)"]) + } + } + + private func merge(_ incoming: [GroupPaymentRequest]) { for request in incoming { if let existing = requests[request.id] { let kept = GroupPaymentRequest.merged(existing: existing, incoming: request) @@ -158,7 +206,6 @@ final class PaymentRequests { } else { requests[request.id] = request } - unavailable.remove(request.id) } } @@ -181,6 +228,7 @@ final class PaymentRequests { break case .applied(let applied): requests[update.id] = applied + persist([applied]) case .refetch: if !refetch.contains(update.id) { refetch.append(update.id) } } @@ -204,6 +252,7 @@ final class PaymentRequests { /// Reads `conversationID`'s OPEN and RESOLVED first pages, and its seal if not held yet. A failed /// read keeps what was held. func refreshChat(_ conversationID: ConversationID) async { + await hydrate(conversationID) async let sealed: Void = loadSeal(for: conversationID) do { async let open = getChatPage(conversationID, .open, Self.openPageSize) @@ -257,6 +306,7 @@ final class PaymentRequests { /// viewer pays under, `nil` when the viewer has no unpaid share. Throws what the read threw. @discardableResult func refresh(_ id: PaymentRequestID) async throws -> Data? { + if requests[id] == nil { await hydrate(id) } do { let fetched = try await getRequest(id) ingest([fetched.request]) diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index f52e74022..a58120e6c 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -1032,7 +1032,8 @@ final class SessionContainer { pageSize: pageSize, pagingToken: pagingToken ) - } + }, + database: database ) conversationController.paymentRequests = self.paymentRequests self.rosterSearch = mentionSearch diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Storage.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Storage.swift new file mode 100644 index 000000000..448b7a679 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest+Storage.swift @@ -0,0 +1,101 @@ +// +// GroupPaymentRequest+Storage.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +// The stored form of a request: its whole `PaymentRequestMetadata`, serialized. The store keeps the +// message rather than columns for its fields because the contract versions and replaces it whole. +// Private-chat text stays in its `.encrypted` form: this model never holds opened text (the chat's +// seal opens it at read time, in ``PaymentRequestText``), so what is encoded is what the server sent. + +extension GroupPaymentRequest.Status { + /// The status's wire number, which the store keeps beside the blob to merge on. + public var wireValue: Int { + switch self { + case .open: Flipcash_Paymentrequest_V1_PaymentRequestStatus.open.rawValue + case .completed: Flipcash_Paymentrequest_V1_PaymentRequestStatus.completed.rawValue + case .closed: Flipcash_Paymentrequest_V1_PaymentRequestStatus.closed.rawValue + case .expired: Flipcash_Paymentrequest_V1_PaymentRequestStatus.expired.rawValue + case .unknown: Flipcash_Paymentrequest_V1_PaymentRequestStatus.unknown.rawValue + } + } +} + +extension GroupPaymentRequest { + + /// The request as a serialized `PaymentRequestMetadata`, paid shares and encrypted text included. + public func serializedMetadata() throws -> Data { + try metadataProto.serializedData() + } + + /// Reads a request back from ``serializedMetadata()``; nil when the bytes don't decode. + public init?(serializedMetadata: Data) { + guard let proto = try? Flipcash_Paymentrequest_V1_PaymentRequestMetadata(serializedBytes: serializedMetadata) else { + return nil + } + self.init(proto) + } + + private var metadataProto: Flipcash_Paymentrequest_V1_PaymentRequestMetadata { + .with { + $0.paymentRequestID = id.proto + if let creator { $0.creator = creator.proto } + if let createdAt { $0.createdAt = .init(date: createdAt) } + $0.status = Self.protoStatus(status) + if let closedAt { $0.closedAt = .init(date: closedAt) } + $0.version = version + if let chatID { + $0.groupChat = .with { + $0.chatID = chatID.proto + if let split { $0.split = split.storedProto } + } + } + } + } + + private static func protoStatus(_ status: Status) -> Flipcash_Paymentrequest_V1_PaymentRequestStatus { + switch status { + case .open: .open + case .completed: .completed + case .closed: .closed + case .expired: .expired + case .unknown: .unknown + } + } +} + +extension GroupPaymentRequest.Split { + /// Unlike ``proto``, which is the create request and sends shares unpaid, keeps each share's settlement. + fileprivate var storedProto: Flipcash_Paymentrequest_V1_Split { + .with { + $0.text = text?.proto + $0.currency = currency + if let endsAt { $0.endsAt = .init(date: endsAt) } + $0.shares = shares.map(\.storedProto) + } + } +} + +extension GroupPaymentRequest.Share { + fileprivate var storedProto: Flipcash_Paymentrequest_V1_Share { + .with { + if let userID { $0.userID = userID.proto } + $0.nativeAmount = nativeAmount + guard let settlement else { return } + $0.paid = .with { + if let paidAt { $0.paidAt = .init(date: paidAt) } + switch settlement { + case .intent(let id): $0.intent = .with { $0.value = id } + case .manual: $0.manual = .init() + case .creator: $0.creator = .init() + case .unknown: break + } + } + } + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database+PaymentRequests.swift b/FlipcashCore/Sources/FlipcashStore/Database+PaymentRequests.swift new file mode 100644 index 000000000..45a54181c --- /dev/null +++ b/FlipcashCore/Sources/FlipcashStore/Database+PaymentRequests.swift @@ -0,0 +1,73 @@ +// +// Database+PaymentRequests.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import SQLite + +nonisolated extension Database { + + // MARK: - Get - + + /// The stored requests of one chat, in no particular order. A row that no longer decodes is skipped. + public func paymentRequests(in conversationID: ConversationID) throws -> [GroupPaymentRequest] { + let p = PaymentRequestTable() + return try reader.prepareRowIterator(p.table.filter(p.conversationID == conversationID.data)) + .compactMap { GroupPaymentRequest(serializedMetadata: $0[p.metadata]) } + } + + /// The stored copy of one request, if any. + public func paymentRequest(id: PaymentRequestID) throws -> GroupPaymentRequest? { + let p = PaymentRequestTable() + return try reader.pluck(p.table.filter(p.id == id.data)).flatMap { + GroupPaymentRequest(serializedMetadata: $0[p.metadata]) + } + } + + // MARK: - Write - + + /// Stores each request under the merge rule the contract gives a client: the greater version wins, + /// and at an equal version EXPIRED beats OPEN. It is one statement per request, evaluated against + /// the stored row, so a stale copy can't overwrite a newer one whatever order writes arrive in. + public func upsertPaymentRequests(_ requests: [GroupPaymentRequest]) throws { + guard !requests.isEmpty else { return } + let open = GroupPaymentRequest.Status.open.wireValue + let expired = GroupPaymentRequest.Status.expired.wireValue + try write { writer in + try writer.transaction(.immediate) { + for request in requests { + let metadata = try request.serializedMetadata() + try writer.run( + """ + INSERT INTO paymentRequest (id, conversationID, version, status, endsAt, createdAt, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + conversationID = excluded.conversationID, + version = excluded.version, + status = excluded.status, + endsAt = excluded.endsAt, + createdAt = excluded.createdAt, + metadata = excluded.metadata + WHERE excluded.version > paymentRequest.version + OR (excluded.version = paymentRequest.version + AND excluded.status = ? AND paymentRequest.status = ?) + """, + request.id.data.datatypeValue, + request.chatID?.data.datatypeValue, + request.version.datatypeValue, + request.status.wireValue, + request.split?.endsAt?.timeIntervalSinceReferenceDate, + request.createdAt?.timeIntervalSinceReferenceDate, + metadata.datatypeValue, + expired, + open + ) + } + } + } + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 3f6892d01..abd8ea13d 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 62 + public static let schemaVersion = 63 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index a425b4db6..841a4e6b4 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -142,6 +142,25 @@ nonisolated public struct RequestedPaymentTable: Sendable { public let paymentRequestID = Expression ("paymentRequestID") } +// One row per payment request, kept so a chat's request cards and details draw offline. The whole +// `PaymentRequestMetadata` is stored serialized (private-chat text still sealed); `version`, `status` +// and `conversationID` sit beside it as columns for the merge rule and the per-chat read. A request +// the server shows no chat for keeps a nil `conversationID`. +nonisolated public struct PaymentRequestTable: Sendable { + public static let name = "paymentRequest" + + public init() {} + + public let table = Table(Self.name) + public let id = Expression ("id") + public let conversationID = Expression ("conversationID") + public let version = Expression ("version") + public let status = Expression ("status") + public let endsAt = Expression ("endsAt") + public let createdAt = Expression ("createdAt") + public let metadata = Expression ("metadata") +} + nonisolated public struct LimitsTable: Sendable { public static let name = "limits" @@ -558,6 +577,21 @@ nonisolated extension Database { }) } + let paymentRequestTable = PaymentRequestTable() + + try writer.transaction(.immediate) { + try writer.run(paymentRequestTable.table.create(ifNotExists: true, withoutRowid: true) { t in + t.column(paymentRequestTable.id, primaryKey: true) + t.column(paymentRequestTable.conversationID) + t.column(paymentRequestTable.version) + t.column(paymentRequestTable.status) + t.column(paymentRequestTable.endsAt) + t.column(paymentRequestTable.createdAt) + t.column(paymentRequestTable.metadata) + }) + try writer.run(paymentRequestTable.table.createIndex(paymentRequestTable.conversationID, ifNotExists: true)) + } + let limitsTable = LimitsTable() try writer.transaction(.immediate) { diff --git a/FlipcashTests/PaymentRequest/PaymentRequestsPersistenceTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestsPersistenceTests.swift new file mode 100644 index 000000000..15addd970 --- /dev/null +++ b/FlipcashTests/PaymentRequest/PaymentRequestsPersistenceTests.swift @@ -0,0 +1,183 @@ +// +// PaymentRequestsPersistenceTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import FlipcashCore +import FlipcashStore +@testable import Flipcash + +private let chat = ConversationID.test(1) +private let creator = UUID() +private let payer = UUID() +private let requestID = PaymentRequestID(data: Data([1])) +private let base = Date(timeIntervalSince1970: 1_800_000_000) + +private func request( + version: UInt64 = 1, + status: GroupPaymentRequest.Status = .open, + text: GroupPaymentRequest.Text = .plaintext(title: "Dinner", description: ""), + paid: Bool = false +) -> GroupPaymentRequest { + GroupPaymentRequest( + id: requestID, + creator: creator, + createdAt: base, + status: status, + closedAt: nil, + version: version, + chatID: chat, + split: .init( + text: text, + currency: "usd", + endsAt: base.addingTimeInterval(86_400), + shares: [ + .init(userID: creator, nativeAmount: 10, paidAt: base, settlement: .creator), + .init(userID: payer, nativeAmount: 10, paidAt: paid ? base : nil, settlement: paid ? .intent(Data(repeating: 9, count: 32)) : nil), + ] + ) + ) +} + +@Suite("Payment request storage") +struct PaymentRequestDatabaseTests { + + @Test("A request with no row is written") + func noRow() throws { + let database = Database.mock + try database.upsertPaymentRequests([request()]) + #expect(try database.paymentRequest(id: requestID) == request()) + } + + @Test("An older version keeps the stored row") + func older() throws { + let database = Database.mock + try database.upsertPaymentRequests([request(version: 3, status: .closed)]) + try database.upsertPaymentRequests([request(version: 2)]) + #expect(try database.paymentRequest(id: requestID)?.version == 3) + #expect(try database.paymentRequest(id: requestID)?.status == .closed) + } + + @Test("A newer version replaces the stored row") + func newer() throws { + let database = Database.mock + try database.upsertPaymentRequests([request(version: 1)]) + try database.upsertPaymentRequests([request(version: 2, paid: true)]) + #expect(try database.paymentRequest(id: requestID) == request(version: 2, paid: true)) + } + + @Test("At an equal version EXPIRED replaces OPEN") + func expiredOverOpen() throws { + let database = Database.mock + try database.upsertPaymentRequests([request(version: 2, status: .open)]) + try database.upsertPaymentRequests([request(version: 2, status: .expired)]) + #expect(try database.paymentRequest(id: requestID)?.status == .expired) + } + + @Test("At an equal version OPEN does not replace EXPIRED") + func openKeepsExpired() throws { + let database = Database.mock + try database.upsertPaymentRequests([request(version: 2, status: .expired)]) + try database.upsertPaymentRequests([request(version: 2, status: .open)]) + #expect(try database.paymentRequest(id: requestID)?.status == .expired) + } + + @Test("Requests read back by chat") + func byChat() throws { + let database = Database.mock + try database.upsertPaymentRequests([request()]) + #expect(try database.paymentRequests(in: chat).count == 1) + #expect(try database.paymentRequests(in: .test(2)).isEmpty) + } + + @Test("The metadata blob round trips, paid shares and plaintext included") + func roundTrip() throws { + let original = request(version: 4, status: .completed, paid: true) + #expect(GroupPaymentRequest(serializedMetadata: try original.serializedMetadata()) == original) + } + + @Test("Encrypted text stays sealed through storage") + func sealedStaysSealed() throws { + let sealed = GroupPaymentRequest.Text.encrypted(scheme: 1, nonce: Data(repeating: 3, count: 24), ciphertext: Data(repeating: 5, count: 40)) + let database = Database.mock + try database.upsertPaymentRequests([request(text: sealed)]) + let stored = try #require(try database.paymentRequest(id: requestID)) + #expect(stored.split?.text == sealed) + } +} + +@MainActor +@Suite("Payment requests persistence") +struct PaymentRequestsPersistenceTests { + + private func store( + _ database: Database, + chatPage: @escaping PaymentRequests.GetChatPage = { _, _, _ in throw ErrorGetChatPaymentRequests.unknown }, + getRequest: @escaping PaymentRequests.GetRequest = { _ in throw ErrorGetPaymentRequest.unknown } + ) -> PaymentRequests { + PaymentRequests(getChatPage: chatPage, getRequest: getRequest, database: database) + } + + @Test("Ingested copies are written through") + func ingestWrites() throws { + let database = Database.mock + store(database).ingest([request(version: 2)]) + #expect(try database.paymentRequest(id: requestID)?.version == 2) + } + + @Test("A restart hydrates a chat's requests while offline") + func hydrateOffline() async throws { + let database = Database.mock + store(database).ingest([request(version: 2)]) + + let restarted = store(database) + #expect(restarted.request(requestID) == nil) + await restarted.refreshChat(chat) + #expect(restarted.request(requestID)?.version == 2) + } + + @Test("A restart hydrates one request by id when the read fails") + func hydrateByID() async throws { + let database = Database.mock + store(database).ingest([request(version: 2)]) + + let restarted = store(database) + await restarted.fetchIfMissing(requestID) + #expect(restarted.request(requestID)?.version == 2) + } + + @Test("An applied update is written whole at its new version") + func appliedWrites() throws { + let database = Database.mock + let store = store(database) + store.ingest([request(version: 1)]) + let share = GroupPaymentRequest.Share(userID: payer, nativeAmount: 10, paidAt: base, settlement: .manual) + store.apply([.init(id: requestID, change: .updates(version: 2, [.sharePaid(share)]))]) + + let stored = try #require(try database.paymentRequest(id: requestID)) + #expect(stored.version == 2) + #expect(stored.split?.shares.last?.settlement == .manual) + } + + @Test("A stale update writes nothing") + func staleWritesNothing() throws { + let database = Database.mock + let store = store(database) + store.ingest([request(version: 2)]) + let before = try database.paymentRequest(id: requestID) + store.apply([.init(id: requestID, change: .updates(version: 2, [.statusChanged(.closed, closedAt: base)]))]) + #expect(try database.paymentRequest(id: requestID) == before) + } + + @Test("A write-through never overwrites a newer stored row") + func staleIngestKeepsNewer() throws { + let database = Database.mock + try database.upsertPaymentRequests([request(version: 5, status: .closed)]) + store(database).ingest([request(version: 3)]) + #expect(try database.paymentRequest(id: requestID)?.version == 5) + } +} From 1e9e7e86aad5fe8af953196396792e8184ddc6a1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 20:48:13 -0400 Subject: [PATCH 2/3] feat(chat): persist group key envelopes for offline decryption Store each private group's key envelope as the server returned it in a new groupKeyEnvelope table (schemaVersion 63 -> 64), so encrypted content opens offline and after a restart. GroupKeyEnvelopes opens the stored envelope first with the same wrapper and scheme checks as a fetched one, and falls back to the network when it is missing or fails. The row follows the in-memory state: replaced by the member's own envelope after a self-wrap, removed on ALREADY_SET, an untrusted wrapper, or an invite copy that fails a check, and deleted with the conversation. The unwrapped key is never written. EncryptedChatClient opens with the stored conversation when the chat has not passed through yet, so the seal no longer needs a GetChat. --- .../Controllers/EncryptedChatClient.swift | 12 +- .../Core/Session/SessionAuthenticator.swift | 8 +- .../Conversation/GroupKeyEnvelopeStore.swift | 61 +++ .../Conversation/GroupKeyEnvelopes.swift | 139 ++++++- .../Database+Conversations.swift | 10 +- .../Database+GroupKeyEnvelopes.swift | 39 ++ .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 22 + .../GroupKeyEnvelopeStoreTests.swift | 390 ++++++++++++++++++ .../GroupKeyEnvelopePersistenceTests.swift | 190 +++++++++ 10 files changed, 847 insertions(+), 26 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopeStore.swift create mode 100644 FlipcashCore/Sources/FlipcashStore/Database+GroupKeyEnvelopes.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/GroupKeyEnvelopeStoreTests.swift create mode 100644 FlipcashTests/PaymentRequest/GroupKeyEnvelopePersistenceTests.swift diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index 9dcc365d4..35a661cb1 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -22,12 +22,15 @@ final class EncryptedChatClient: Sendable { private let client: FlipClient private let keyring: ChatKeyring + /// The chat as the database holds it, for opening messages while the server can't be reached. + private let storedConversation: @Sendable (ConversationID) -> Conversation? /// Every chat seen on its way through, so a message can be opened without a `GetChat`. private let conversations = OSAllocatedUnfairLock<[ConversationID: Conversation]>(initialState: [:]) - init(client: FlipClient, keyring: ChatKeyring) { + init(client: FlipClient, storedConversation: @escaping @Sendable (ConversationID) -> Conversation? = { _ in nil }, keyring: ChatKeyring) { self.client = client + self.storedConversation = storedConversation self.keyring = keyring } @@ -43,8 +46,13 @@ final class EncryptedChatClient: Sendable { return fetched } - /// How `conversationID`'s messages open; waits on the key when the chat can't be fetched. + /// How `conversationID`'s messages open; waits on the key when the chat is neither stored nor + /// fetchable. The stored copy is enough to open with and is not remembered, since a send needs the + /// server's current encryption choice. private func opener(_ conversationID: ConversationID, owner: KeyPair) async -> ChatOpener { + if let known = conversations.withLock({ $0[conversationID] }) ?? storedConversation(conversationID) { + return await keyring.opener(for: known) + } guard let conversation = try? await conversation(conversationID, owner: owner) else { return .awaitingKey } return await keyring.opener(for: conversation) } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index a58120e6c..7a38611e3 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -684,7 +684,7 @@ final class SessionContainer { /// NotificationService extension reads it to decide whether a push may interrupt. let chatArchive: ChatArchiveStore - /// The signed-in user's private-group chat keys, in memory and reopened from the server. + /// The signed-in user's private-group chat keys, in memory and reopened from the stored envelope or the server. let groupKeys: GroupKeyEnvelopes /// The private groups the signed-in user has asked to join. @@ -800,7 +800,8 @@ final class SessionContainer { }, resolveKey: { [flipClient, owner = session.ownerKeyPair] userID in try await flipClient.resolveUserID(userID, owner: owner) - } + }, + envelopeStore: database ) self.groupKeys = groupKeys @@ -816,6 +817,9 @@ final class SessionContainer { let chatClient = EncryptedChatClient( client: flipClient, + storedConversation: { [database] conversationID in + try? database.getConversations().first { $0.id == conversationID } + }, keyring: ChatKeyring( owner: session.ownerKeyPair, selfUserID: session.userID, diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopeStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopeStore.swift new file mode 100644 index 000000000..16be8c92e --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopeStore.swift @@ -0,0 +1,61 @@ +// +// GroupKeyEnvelopeStore.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// Where the signed-in user's private-group key envelopes are kept between launches, so an +/// encrypted chat opens offline. +/// +/// It holds the envelope as the server returned it, never the unwrapped chat key: the key is opened +/// from the envelope with the owner key each launch, with the same checks as a fetched one. +public protocol GroupKeyEnvelopeStore: Sendable { + /// The envelope stored for `conversationID`, or nil when none is stored or it no longer decodes. + func material(for conversationID: ConversationID) throws -> GroupKeyMaterial? + /// Stores `material` for `conversationID`, replacing any earlier one. + func store(_ material: GroupKeyMaterial, for conversationID: ConversationID) throws + /// Removes whatever is stored for `conversationID`. + func remove(for conversationID: ConversationID) throws +} + +extension GroupKeyMaterial { + + /// The material as the bytes of the `GetKeyEnvelopeResponse` the server returns it in. + public func serialized() throws -> Data { + var response = Flipcash_Chat_V1_GetKeyEnvelopeResponse() + response.result = .ok + switch self { + case .own(let envelope, let wrappedBy): + response.keyEnvelope = envelope.proto + if let wrappedBy { response.wrappedBy = wrappedBy.proto } + case .invite(let bundle, let wrappedBy, let wrappedByPublicKey): + response.inviteKeyBundle = .with { + $0.inviteID = bundle.inviteID.proto + $0.keyEnvelope = bundle.keyEnvelope.proto + $0.secretEnvelope = bundle.secretEnvelope.proto + } + if let wrappedBy { response.wrappedBy = wrappedBy.proto } + if let wrappedByPublicKey { response.wrappedByPublicKey = wrappedByPublicKey.proto } + } + return try response.serializedData() + } + + /// The material `serialized()` wrote, or nil when `data` is not a response carrying an envelope. + public init?(serialized data: Data) { + guard let response = try? Flipcash_Chat_V1_GetKeyEnvelopeResponse(serializedBytes: data) else { return nil } + let wrappedBy = response.hasWrappedBy ? try? UserID(data: response.wrappedBy.value) : nil + let wrappedByPublicKey = response.hasWrappedByPublicKey ? try? PublicKey(response.wrappedByPublicKey.value) : nil + switch response.envelope { + case .keyEnvelope(let envelope): + self = .own(envelope: ConversationKeyEnvelope(envelope), wrappedBy: wrappedBy) + case .inviteKeyBundle(let bundle): + self = .invite(InviteKeyBundle(bundle), wrappedBy: wrappedBy, wrappedByPublicKey: wrappedByPublicKey) + case nil: + return nil + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopes.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopes.swift index 5d3be395f..7fa897072 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopes.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/GroupKeyEnvelopes.swift @@ -29,7 +29,7 @@ public protocol GroupKeySealing: Sendable { /// What the server holds for the viewer in a private group: their own envelope, or, after joining /// by invite and before storing one, the invite's copy of the chat key. -public enum GroupKeyMaterial: Sendable { +public enum GroupKeyMaterial: Sendable, Equatable { case own(envelope: ConversationKeyEnvelope, wrappedBy: UserID?) case invite(InviteKeyBundle, wrappedBy: UserID?, wrappedByPublicKey: PublicKey?) } @@ -60,8 +60,12 @@ public struct InviteBundleNotOpened: Error {} /// The signed-in user's private-group chat keys, held in memory only. /// -/// A key is never written to disk: the server keeps the viewer's self-wrapped envelope, which -/// opens with the owner key, so after a relaunch the key is fetched and opened again on first use. +/// A key is never written to disk. With a ``GroupKeyEnvelopeStore``, the envelope the server +/// returned is kept instead, so after a relaunch the key is opened from it on first use, offline, +/// with the same checks as a fetched one. A stored envelope that fails a check, or that no longer +/// matches what the server holds, is removed and the key is fetched. Without a store, or with +/// nothing stored, the server's self-wrapped envelope, which opens with the owner key, is fetched +/// and opened on first use. /// A creator whose key was lost before its envelope landed sees `noEnvelope` and generates a new /// one; nothing was encrypted under the lost key. /// @@ -82,6 +86,7 @@ public actor GroupKeyEnvelopes { private let getEnvelope: GetEnvelope private let resolveKey: ResolveKey private let opensInviteBundles: Bool + private let envelopeStore: (any GroupKeyEnvelopeStore)? private var keys: [ConversationID: Data] = [:] @@ -92,7 +97,8 @@ public actor GroupKeyEnvelopes { setEnvelope: @escaping SetEnvelope, getEnvelope: @escaping GetEnvelope, resolveKey: @escaping ResolveKey, - opensInviteBundles: Bool = true + opensInviteBundles: Bool = true, + envelopeStore: (any GroupKeyEnvelopeStore)? = nil ) { self.sealer = sealer self.owner = owner @@ -101,6 +107,7 @@ public actor GroupKeyEnvelopes { self.getEnvelope = getEnvelope self.resolveKey = resolveKey self.opensInviteBundles = opensInviteBundles + self.envelopeStore = envelopeStore } /// Generates `conversationID`'s chat key and stores the creator's own envelope: the second @@ -114,8 +121,10 @@ public actor GroupKeyEnvelopes { do { try await setEnvelope(conversationID, envelope) keys[conversationID] = key + persist(.own(envelope: envelope, wrappedBy: selfUserID), for: conversationID) } catch ErrorSetKeyEnvelope.alreadySet { keys[conversationID] = nil + forgetStored(conversationID) } } @@ -139,34 +148,80 @@ public actor GroupKeyEnvelopes { if let key = keys[conversationID] { return key } - let stored: GroupKeyMaterial + if let key = await openStored(for: conversationID, creator: creator) { + return key + } + let fetched: GroupKeyMaterial do { - stored = try await getEnvelope(conversationID) + fetched = try await getEnvelope(conversationID) } catch ErrorGetKeyEnvelope.noEnvelope where creator == selfUserID { try await storeNewKey(for: conversationID) if let key = keys[conversationID] { return key } - stored = try await getEnvelope(conversationID) + fetched = try await getEnvelope(conversationID) } let key: Data - switch stored { + do { + key = try await unlock(fetched, creator: creator, in: conversationID) + } catch let distrusted as Distrusted { + forgetStored(conversationID) + throw distrusted.underlying + } + keys[conversationID] = key + return key + } + + /// A key from the envelope kept for `conversationID`, nil when none is kept or it doesn't open, + /// in which case the caller fetches. One that fails a check is removed first. + private func openStored(for conversationID: ConversationID, creator: UserID?) async -> Data? { + guard let envelopeStore, let material = try? envelopeStore.material(for: conversationID) else { return nil } + do { + let key = try await unlock(material, creator: creator, in: conversationID, stored: true) + keys[conversationID] = key + return key + } catch is Distrusted { + forgetStored(conversationID) + return nil + } catch { + return nil + } + } + + /// Opens the chat key from `material`, then makes what is kept follow the server: the + /// material as fetched, or the viewer's own envelope once it replaces a creator's or an + /// invite's copy, or nothing once the server holds a different one. `stored` material is + /// already kept, so only a change is written. + /// + /// Throws ``Distrusted`` for a failed check, which the caller answers by forgetting the kept + /// envelope; any other error (a lookup that failed, an invite copy not yet openable) may pass. + private func unlock(_ material: GroupKeyMaterial, creator: UserID?, in conversationID: ConversationID, stored: Bool = false) async throws -> Data { + let key: Data + var replacement: SelfWrap = .notAttempted + switch material { case .own(let envelope, let wrappedBy): if wrappedBy == nil || wrappedBy == selfUserID { - key = try sealer.unwrap(envelope, owner: owner, wrapper: owner.publicKey, conversationID: conversationID) + key = try distrusting { try sealer.unwrap(envelope, owner: owner, wrapper: owner.publicKey, conversationID: conversationID) } } else if let wrappedBy, wrappedBy == creator { let creatorKey = try await resolveKey(wrappedBy) - key = try sealer.unwrap(envelope, owner: owner, wrapper: creatorKey, conversationID: conversationID) - await storeSelfWrapped(key, in: conversationID) + key = try distrusting { try sealer.unwrap(envelope, owner: owner, wrapper: creatorKey, conversationID: conversationID) } + replacement = await storeSelfWrapped(key, in: conversationID) } else { - throw GroupKeyNotSelfWrapped() + throw Distrusted(underlying: GroupKeyNotSelfWrapped()) } case .invite(let bundle, let wrappedBy, let wrappedByPublicKey): key = try await open(bundle, wrappedBy: wrappedBy, wrappedByPublicKey: wrappedByPublicKey, creator: creator, in: conversationID) - await storeSelfWrapped(key, in: conversationID) + replacement = await storeSelfWrapped(key, in: conversationID) + } + switch replacement { + case .replaced(let own): + persist(own, for: conversationID) + case .alreadySet: + forgetStored(conversationID) + case .notAttempted, .failed: + if !stored { persist(material, for: conversationID) } } - keys[conversationID] = key return key } @@ -179,7 +234,7 @@ public actor GroupKeyEnvelopes { throw InviteBundleNotOpened() } guard wrappedBy == creator else { - throw GroupKeyUnusable(failsAt: .wrappedBy) + throw Distrusted(underlying: GroupKeyUnusable(failsAt: .wrappedBy)) } let creatorKey: PublicKey do { @@ -188,15 +243,61 @@ public actor GroupKeyEnvelopes { guard let wrappedByPublicKey else { throw error } creatorKey = wrappedByPublicKey } - return try sealer.open(bundle, owner: owner, creatorPublicKey: creatorKey, wrappedByCreator: true, conversationID: conversationID) + return try distrusting { + try sealer.open(bundle, owner: owner, creatorPublicKey: creatorKey, wrappedByCreator: true, conversationID: conversationID) + } + } + + /// What replacing a creator's or an invite's envelope with the viewer's own came to. + private enum SelfWrap { + case notAttempted + /// The server now holds this envelope. + case replaced(GroupKeyMaterial) + /// The server already holds an envelope of the viewer's, so the one just opened is stale. + case alreadySet + case failed } /// Replaces the creator-wrapped envelope with the viewer's own. A failure leaves the creator's /// in place, which still opens next time. - private func storeSelfWrapped(_ key: Data, in conversationID: ConversationID) async { + private func storeSelfWrapped(_ key: Data, in conversationID: ConversationID) async -> SelfWrap { guard let envelope = try? sealer.wrap(groupKey: key, owner: owner, recipient: owner.publicKey, conversationID: conversationID) else { - return + return .failed + } + do { + try await setEnvelope(conversationID, envelope) + return .replaced(.own(envelope: envelope, wrappedBy: selfUserID)) + } catch ErrorSetKeyEnvelope.alreadySet { + return .alreadySet + } catch { + return .failed + } + } + + /// A check on an envelope failed: it will not open, so no copy of it is worth keeping. + private struct Distrusted: Error { + let underlying: Error + } + + private func distrusting(_ body: () throws -> T) throws -> T { + do { + return try body() + } catch { + throw Distrusted(underlying: error) + } + } + + private func persist(_ material: GroupKeyMaterial, for conversationID: ConversationID) { + guard let envelopeStore else { return } + do { + try envelopeStore.store(material, for: conversationID) + } catch { + // A row left behind may be stale; take it out rather than let it open the chat. + forgetStored(conversationID) } - try? await setEnvelope(conversationID, envelope) + } + + private func forgetStored(_ conversationID: ConversationID) { + try? envelopeStore?.remove(for: conversationID) } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 698a711fe..d6566451e 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -339,6 +339,7 @@ nonisolated extension Database { try write { writer in let c = ConversationTable() let m = ConversationMemberTable() + let k = GroupKeyEnvelopeTable() let ids = conversations.map(\.id.data) // IMMEDIATE: this transaction reads before it writes. A DEFERRED one that reads first fails // the write with SQLITE_BUSY at once when another writer holds the lock, without consulting @@ -357,6 +358,7 @@ nonisolated extension Database { if !doomed.isEmpty { try writer.run(c.table.filter(doomed.contains(c.id)).delete()) try writer.run(m.table.filter(doomed.contains(m.conversationId)).delete()) + try writer.run(k.table.filter(doomed.contains(k.conversationID)).delete()) } for conversation in conversations where conversation.type == type { try writeConversation(conversation) @@ -376,9 +378,11 @@ nonisolated extension Database { let c = ConversationTable() let m = ConversationMemberTable() let g = GroupMembershipTable() + let k = GroupKeyEnvelopeTable() try writer.transaction(.immediate) { let doomed = departed.map(\.data) if !doomed.isEmpty { + try writer.run(k.table.filter(doomed.contains(k.conversationID)).delete()) try writer.run(c.table.filter(doomed.contains(c.id)).delete()) try writer.run(m.table.filter(doomed.contains(m.conversationId)).delete()) try writer.run(g.table.filter(doomed.contains(g.conversationId)).delete()) @@ -469,16 +473,18 @@ nonisolated extension Database { } } - /// Removes a conversation the signed-in user has left (or been removed from): its row and member - /// rows. Messages are left in place, orphaned but unread — the same treatment + /// Removes a conversation the signed-in user has left (or been removed from): its row, member + /// rows and stored key envelope. Messages are left in place, orphaned but unread — the same treatment /// `replaceConversationFeed` gives a conversation that drops out of a feed snapshot. public func deleteConversation(conversationID: ConversationID) throws { try write { writer in let c = ConversationTable() let m = ConversationMemberTable() + let k = GroupKeyEnvelopeTable() try writer.transaction(.immediate) { try writer.run(c.table.filter(c.id == conversationID.data).delete()) try writer.run(m.table.filter(m.conversationId == conversationID.data).delete()) + try writer.run(k.table.filter(k.conversationID == conversationID.data).delete()) } } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+GroupKeyEnvelopes.swift b/FlipcashCore/Sources/FlipcashStore/Database+GroupKeyEnvelopes.swift new file mode 100644 index 000000000..e0b7d475e --- /dev/null +++ b/FlipcashCore/Sources/FlipcashStore/Database+GroupKeyEnvelopes.swift @@ -0,0 +1,39 @@ +// +// Database+GroupKeyEnvelopes.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import SQLite + +/// The database keeps each private group's key envelope as the server returned it, so the chat key +/// can be opened offline. The conversation tables' deletes remove it with the chat. +nonisolated extension Database: GroupKeyEnvelopeStore { + + public func material(for conversationID: ConversationID) throws -> GroupKeyMaterial? { + let k = GroupKeyEnvelopeTable() + return try reader.pluck(k.table.filter(k.conversationID == conversationID.data)).flatMap { + GroupKeyMaterial(serialized: $0[k.envelope]) + } + } + + public func store(_ material: GroupKeyMaterial, for conversationID: ConversationID) throws { + let bytes = try material.serialized() + try write { writer in + let k = GroupKeyEnvelopeTable() + try writer.run( + k.table.insert(or: .replace, k.conversationID <- conversationID.data, k.envelope <- bytes) + ) + } + } + + public func remove(for conversationID: ConversationID) throws { + try write { writer in + let k = GroupKeyEnvelopeTable() + try writer.run(k.table.filter(k.conversationID == conversationID.data).delete()) + } + } +} diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index abd8ea13d..c3d831aa7 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 63 + public static let schemaVersion = 64 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index 841a4e6b4..a4c3d25ec 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -161,6 +161,19 @@ nonisolated public struct PaymentRequestTable: Sendable { public let metadata = Expression ("metadata") } +// One row per private group: the viewer's key envelope exactly as the server returned it, so the +// chat opens offline and after a restart. `envelope` is the serialized `GetKeyEnvelopeResponse` +// (scheme, nonce, ciphertext, and who wrapped it); the unwrapped chat key is never stored. +nonisolated public struct GroupKeyEnvelopeTable: Sendable { + public static let name = "groupKeyEnvelope" + + public init() {} + + public let table = Table(Self.name) + public let conversationID = Expression ("conversationID") + public let envelope = Expression ("envelope") +} + nonisolated public struct LimitsTable: Sendable { public static let name = "limits" @@ -592,6 +605,15 @@ nonisolated extension Database { try writer.run(paymentRequestTable.table.createIndex(paymentRequestTable.conversationID, ifNotExists: true)) } + let groupKeyEnvelopeTable = GroupKeyEnvelopeTable() + + try writer.transaction(.immediate) { + try writer.run(groupKeyEnvelopeTable.table.create(ifNotExists: true, withoutRowid: true) { t in + t.column(groupKeyEnvelopeTable.conversationID, primaryKey: true) + t.column(groupKeyEnvelopeTable.envelope) + }) + } + let limitsTable = LimitsTable() try writer.transaction(.immediate) { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/GroupKeyEnvelopeStoreTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/GroupKeyEnvelopeStoreTests.swift new file mode 100644 index 000000000..31f689aa5 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/GroupKeyEnvelopeStoreTests.swift @@ -0,0 +1,390 @@ +// +// GroupKeyEnvelopeStoreTests.swift +// FlipcashCoreTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashAPI + +@testable import FlipcashCore + +/// SharedCore exports its own `KeyPair`; these tests mean the app's. +private typealias KeyPair = FlipcashCore.KeyPair + +@Suite("GroupKeyEnvelopes storage") +struct GroupKeyEnvelopeStoreTests { + + private struct Offline: Error {} + + private final class MemoryStore: GroupKeyEnvelopeStore, @unchecked Sendable { + private let lock = NSLock() + private var rows: [ConversationID: Data] = [:] + var failsToStore = false + + func material(for conversationID: ConversationID) throws -> GroupKeyMaterial? { + lock.withLock { rows[conversationID] }.flatMap { GroupKeyMaterial(serialized: $0) } + } + func store(_ material: GroupKeyMaterial, for conversationID: ConversationID) throws { + if failsToStore { throw Offline() } + let bytes = try material.serialized() + lock.withLock { rows[conversationID] = bytes } + } + func remove(for conversationID: ConversationID) throws { + lock.withLock { rows[conversationID] = nil } + } + func bytes(_ conversationID: ConversationID) -> Data? { lock.withLock { rows[conversationID] } } + } + + /// The server's side: what it holds for the viewer, what it accepts, and how often it is asked. + private final class Server: @unchecked Sendable { + private let lock = NSLock() + private var _material: GroupKeyMaterial? + private var _gets = 0 + private var _sets = 0 + var offline = false + var setError: Error? + + init(_ material: GroupKeyMaterial? = nil) { _material = material } + + var material: GroupKeyMaterial? { lock.withLock { _material } } + var gets: Int { lock.withLock { _gets } } + var sets: Int { lock.withLock { _sets } } + + func get() throws -> GroupKeyMaterial { + try lock.withLock { + _gets += 1 + if offline { throw Offline() } + guard let _material else { throw ErrorGetKeyEnvelope.noEnvelope } + return _material + } + } + func set(_ envelope: ConversationKeyEnvelope, selfID: UserID) throws { + try lock.withLock { + _sets += 1 + if let setError { throw setError } + _material = .own(envelope: envelope, wrappedBy: selfID) + } + } + } + + /// Real crypto for envelopes; an invite copy opens to its key envelope's ciphertext, as the + /// other suite's fake does, because a real bundle needs the inviter's secret. + private struct InviteFakingSealer: GroupKeySealing { + let real = SharedCoreGroupKeySealer() + func newGroupKey() -> Data { real.newGroupKey() } + func wrap(groupKey: Data, owner: KeyPair, recipient: PublicKey, conversationID: ConversationID) throws -> ConversationKeyEnvelope { + try real.wrap(groupKey: groupKey, owner: owner, recipient: recipient, conversationID: conversationID) + } + func unwrap(_ envelope: ConversationKeyEnvelope, owner: KeyPair, wrapper: PublicKey, conversationID: ConversationID) throws -> Data { + try real.unwrap(envelope, owner: owner, wrapper: wrapper, conversationID: conversationID) + } + func open(_ bundle: InviteKeyBundle, owner: KeyPair, creatorPublicKey: PublicKey, wrappedByCreator: Bool, conversationID: ConversationID) throws -> Data { + guard wrappedByCreator else { throw GroupKeyUnusable(failsAt: .wrappedBy) } + return bundle.keyEnvelope.ciphertext + } + } + + private let sealer = InviteFakingSealer() + private let owner = KeyPair.generate()! + private let creator = KeyPair.generate()! + private let selfID = UUID() + private let creatorID = UUID() + private let chat = ConversationID(data: Data(repeating: 3, count: 32)) + private let groupKey = Data((0..<32).map { UInt8(200 - $0) }) + + private func envelopes(_ server: Server, _ store: MemoryStore?, opensInviteBundles: Bool = true) -> GroupKeyEnvelopes { + GroupKeyEnvelopes( + sealer: sealer, + owner: owner, + selfUserID: selfID, + setEnvelope: { [selfID] _, envelope in try server.set(envelope, selfID: selfID) }, + getEnvelope: { _ in try server.get() }, + resolveKey: { [creatorID, creator] userID in + guard userID == creatorID else { throw ErrorGetKeyEnvelope.notFound } + return creator.publicKey + }, + opensInviteBundles: opensInviteBundles, + envelopeStore: store + ) + } + + private func selfWrapped(_ key: Data? = nil) throws -> GroupKeyMaterial { + .own(envelope: try sealer.wrap(groupKey: key ?? groupKey, owner: owner, recipient: owner.publicKey, conversationID: chat), wrappedBy: selfID) + } + + private func creatorWrapped() throws -> GroupKeyMaterial { + .own(envelope: try sealer.wrap(groupKey: groupKey, owner: creator, recipient: owner.publicKey, conversationID: chat), wrappedBy: creatorID) + } + + private func stranger() throws -> GroupKeyMaterial { + let other = KeyPair.generate()! + return .own(envelope: try sealer.wrap(groupKey: groupKey, owner: other, recipient: owner.publicKey, conversationID: chat), wrappedBy: UUID()) + } + + private func envelope(of material: GroupKeyMaterial?) -> ConversationKeyEnvelope? { + guard case .own(let envelope, _) = material else { return nil } + return envelope + } + + // MARK: - Read - + + @Test("A stored envelope opens after a restart with the network down, without a fetch") + func opensOfflineAfterRestart() async throws { + let server = Server(try selfWrapped()) + let store = MemoryStore() + #expect(try await envelopes(server, store).groupKey(for: chat, creator: creatorID) == groupKey) + #expect(server.gets == 1) + + server.offline = true + let restarted = envelopes(server, store) + #expect(try await restarted.groupKey(for: chat, creator: creatorID) == groupKey) + #expect(server.gets == 1) + } + + @Test("A stored envelope that fails to unwrap falls back to the network and is replaced") + func badStoredFallsBack() async throws { + let server = Server(try selfWrapped()) + let store = MemoryStore() + try store.store(try stranger(), for: chat) + + #expect(try await envelopes(server, store).groupKey(for: chat, creator: creatorID) == groupKey) + #expect(server.gets == 1) + #expect(try store.material(for: chat) == server.material) + } + + @Test("Without a store, nothing is kept and every launch fetches") + func noStore() async throws { + let server = Server(try selfWrapped()) + _ = try await envelopes(server, nil).groupKey(for: chat, creator: creatorID) + _ = try await envelopes(server, nil).groupKey(for: chat, creator: creatorID) + #expect(server.gets == 2) + } + + // MARK: - Write - + + @Test("A fetch stores the envelope exactly as the server returned it") + func fetchStores() async throws { + let server = Server(try selfWrapped()) + let store = MemoryStore() + _ = try await envelopes(server, store).groupKey(for: chat, creator: creatorID) + #expect(try store.material(for: chat) == server.material) + #expect(envelope(of: try store.material(for: chat))?.scheme == SharedCoreGroupKeySealer.scheme.rawValue) + } + + @Test("The unwrapped key is never written") + func keyNeverWritten() async throws { + let server = Server(try selfWrapped()) + let store = MemoryStore() + let sut = envelopes(server, store) + _ = try await sut.groupKey(for: chat, creator: creatorID) + + let bytes = try #require(store.bytes(chat)) + #expect(bytes.range(of: groupKey) == nil) + + // Nor when the creator generates the key. + let created = MemoryStore() + let createdServer = Server() + let creating = envelopes(createdServer, created) + try await creating.storeNewKey(for: chat) + let key = try await creating.groupKey(for: chat, creator: selfID) + #expect(key.count == 32) + #expect(try #require(created.bytes(chat)).range(of: key) == nil) + } + + @Test("A creator's new key stores their own envelope, which opens offline") + func newKeyStores() async throws { + let server = Server() + let store = MemoryStore() + let creating = envelopes(server, store) + try await creating.storeNewKey(for: chat) + #expect(try store.material(for: chat) == server.material) + + server.offline = true + let key = try await envelopes(server, store).groupKey(for: chat, creator: selfID) + #expect(key.count == 32) + #expect(server.gets == 0) + } + + // MARK: - Invalidation - + + @Test("ALREADY_SET on a new key removes the row, and the stored envelope wins on the next read") + func alreadySetOnNewKey() async throws { + let server = Server(try selfWrapped()) + server.setError = ErrorSetKeyEnvelope.alreadySet + let store = MemoryStore() + try store.store(try stranger(), for: chat) + + try await envelopes(server, store).storeNewKey(for: chat) + #expect(store.bytes(chat) == nil) + + #expect(try await envelopes(server, store).groupKey(for: chat, creator: selfID) == groupKey) + #expect(try store.material(for: chat) == server.material) + } + + @Test("A wrapper that is neither the viewer nor the creator removes the row") + func untrustedWrapperRemovesRow() async throws { + let untrusted = try stranger() + let server = Server(untrusted) + let store = MemoryStore() + try store.store(untrusted, for: chat) + + await #expect(throws: GroupKeyNotSelfWrapped.self) { + try await envelopes(server, store).groupKey(for: chat, creator: creatorID) + } + #expect(store.bytes(chat) == nil) + } + + @Test("An untrusted envelope the server returns is not stored") + func untrustedFetchNotStored() async throws { + let server = Server(try stranger()) + let store = MemoryStore() + await #expect(throws: GroupKeyNotSelfWrapped.self) { + try await envelopes(server, store).groupKey(for: chat, creator: creatorID) + } + #expect(store.bytes(chat) == nil) + } + + @Test("A creator-wrapped envelope is replaced by the member's own once the server takes it") + func creatorWrapReplacedByOwn() async throws { + let server = Server(try creatorWrapped()) + let store = MemoryStore() + try store.store(try creatorWrapped(), for: chat) + + server.offline = false + #expect(try await envelopes(server, store).groupKey(for: chat, creator: creatorID) == groupKey) + #expect(server.sets == 1) + #expect(try store.material(for: chat) == server.material) + guard case .own(_, let wrappedBy) = try store.material(for: chat) else { Issue.record("not an own envelope"); return } + #expect(wrappedBy == selfID) + + // Offline from here on: the member's own envelope needs no creator lookup. + server.offline = true + #expect(try await envelopes(server, store).groupKey(for: chat, creator: creatorID) == groupKey) + } + + @Test("A creator-wrapped envelope stays stored when the self-wrap fails") + func creatorWrapKeptWhenSelfWrapFails() async throws { + let wrapped = try creatorWrapped() + let server = Server(wrapped) + server.setError = Offline() + let store = MemoryStore() + + #expect(try await envelopes(server, store).groupKey(for: chat, creator: creatorID) == groupKey) + #expect(try store.material(for: chat) == wrapped) + } + + @Test("ALREADY_SET on the self-wrap removes the creator-wrapped row") + func selfWrapAlreadySetRemovesRow() async throws { + let wrapped = try creatorWrapped() + let server = Server(wrapped) + server.setError = ErrorSetKeyEnvelope.alreadySet + let store = MemoryStore() + try store.store(wrapped, for: chat) + + #expect(try await envelopes(server, store).groupKey(for: chat, creator: creatorID) == groupKey) + #expect(store.bytes(chat) == nil) + } + + @Test("A storage failure leaves no row behind") + func storageFailureLeavesNoRow() async throws { + let server = Server(try selfWrapped(Data(repeating: 9, count: 32))) + let store = MemoryStore() + try store.store(try stranger(), for: chat) + store.failsToStore = true + + _ = try await envelopes(server, store).groupKey(for: chat, creator: creatorID) + #expect(store.bytes(chat) == nil) + } + + // MARK: - Invite copies - + + private func inviteCopy(wrappedBy: UserID?, wrappedByPublicKey: PublicKey? = nil) throws -> GroupKeyMaterial { + .invite( + InviteKeyBundle( + inviteID: InviteID(data: Data(repeating: 7, count: 32)), + keyEnvelope: InviteKeyEnvelope(scheme: 1, nonce: Data(), ciphertext: groupKey, signature: Data()), + secretEnvelope: InviteSecretEnvelope(scheme: 1, nonce: Data(), ciphertext: Data()) + ), + wrappedBy: wrappedBy, + wrappedByPublicKey: wrappedByPublicKey + ) + } + + @Test("An invite copy is replaced by the member's own envelope, and that opens offline") + func inviteCopyReplacedByOwn() async throws { + let server = Server(try inviteCopy(wrappedBy: creatorID)) + let store = MemoryStore() + + #expect(try await envelopes(server, store).groupKey(for: chat, creator: creatorID) == groupKey) + #expect(try store.material(for: chat) == server.material) + guard case .own = try store.material(for: chat) else { Issue.record("still the invite copy"); return } + + server.offline = true + #expect(try await envelopes(server, store).groupKey(for: chat, creator: creatorID) == groupKey) + } + + @Test("An invite copy stays stored while the self-wrap hasn't landed, and opens offline from there") + func inviteCopyKeptUntilSelfWrap() async throws { + let copy = try inviteCopy(wrappedBy: creatorID, wrappedByPublicKey: creator.publicKey) + let server = Server(copy) + server.setError = Offline() + let store = MemoryStore() + + #expect(try await envelopes(server, store).groupKey(for: chat, creator: creatorID) == groupKey) + #expect(try store.material(for: chat) == copy) + + // The sent-along creator key stands in for the lookup that can't run offline. + let offlineLookup = GroupKeyEnvelopes( + sealer: sealer, owner: owner, selfUserID: selfID, + setEnvelope: { _, _ in throw Offline() }, + getEnvelope: { _ in throw Offline() }, + resolveKey: { _ in throw Offline() }, + envelopeStore: store + ) + #expect(try await offlineLookup.groupKey(for: chat, creator: creatorID) == groupKey) + } + + @Test("An invite copy that fails a check removes the row and is not stored") + func unusableInviteCopyRemovesRow() async throws { + let copy = try inviteCopy(wrappedBy: UUID()) + let server = Server(copy) + let store = MemoryStore() + + await #expect(throws: GroupKeyUnusable.self) { + try await envelopes(server, store).groupKey(for: chat, creator: creatorID) + } + #expect(store.bytes(chat) == nil) + + try store.store(copy, for: chat) + await #expect(throws: GroupKeyUnusable.self) { + try await envelopes(server, store).groupKey(for: chat, creator: creatorID) + } + #expect(store.bytes(chat) == nil) + } + + @Test("A stored invite copy the instance can't open yet is kept") + func notYetOpenedInviteCopyKept() async throws { + let copy = try inviteCopy(wrappedBy: creatorID) + let server = Server(copy) + let store = MemoryStore() + try store.store(copy, for: chat) + + await #expect(throws: InviteBundleNotOpened.self) { + try await envelopes(server, store, opensInviteBundles: false).groupKey(for: chat, creator: creatorID) + } + #expect(try store.material(for: chat) == copy) + } + + // MARK: - Serialization - + + @Test("Material round trips through its serialized form") + func roundTrip() throws { + for material in [try selfWrapped(), try creatorWrapped(), try inviteCopy(wrappedBy: creatorID, wrappedByPublicKey: creator.publicKey), try inviteCopy(wrappedBy: nil)] { + #expect(GroupKeyMaterial(serialized: try material.serialized()) == material) + } + #expect(GroupKeyMaterial(serialized: Data([0xFF, 0xFF])) == nil) + } +} diff --git a/FlipcashTests/PaymentRequest/GroupKeyEnvelopePersistenceTests.swift b/FlipcashTests/PaymentRequest/GroupKeyEnvelopePersistenceTests.swift new file mode 100644 index 000000000..8c28f0faa --- /dev/null +++ b/FlipcashTests/PaymentRequest/GroupKeyEnvelopePersistenceTests.swift @@ -0,0 +1,190 @@ +// +// GroupKeyEnvelopePersistenceTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import FlipcashCore +import FlipcashStore +@testable import Flipcash + +private typealias KeyPair = FlipcashCore.KeyPair + +private let chat = ConversationID.test(1) +private let selfID = UUID() +private let sealer = SharedCoreGroupKeySealer() +private let groupKey = Data((0..<32).map { UInt8(100 + $0) }) + +private func ownEnvelope(for owner: KeyPair) throws -> GroupKeyMaterial { + .own(envelope: try sealer.wrap(groupKey: groupKey, owner: owner, recipient: owner.publicKey, conversationID: chat), wrappedBy: selfID) +} + +private func group(_ id: ConversationID) -> Conversation { + Conversation(id: id, members: [], lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 100), type: .group, creator: selfID, isPrivate: true) +} + +@Suite("Group key envelope storage") +struct DatabaseGroupKeyEnvelopeTests { + + @Test("An envelope reads back as stored, and a later store replaces it") + func roundTripAndReplace() throws { + let database = Database.mock + let owner = KeyPair.generate()! + let first = try ownEnvelope(for: owner) + try database.store(first, for: chat) + #expect(try database.material(for: chat) == first) + + let second = try ownEnvelope(for: KeyPair.generate()!) + try database.store(second, for: chat) + #expect(try database.material(for: chat) == second) + #expect(try database.material(for: .test(2)) == nil) + } + + @Test("The stored bytes never hold the unwrapped key") + func storedBytesHoldNoKey() throws { + let owner = KeyPair.generate()! + let material = try ownEnvelope(for: owner) + let bytes = try material.serialized() + #expect(bytes.range(of: groupKey) == nil) + guard case .own(let envelope, _) = material else { return } + #expect(bytes.range(of: envelope.ciphertext) != nil) + } + + @Test("Removing a row leaves the others") + func remove() throws { + let database = Database.mock + let material = try ownEnvelope(for: KeyPair.generate()!) + try database.store(material, for: chat) + try database.store(material, for: .test(2)) + try database.remove(for: chat) + #expect(try database.material(for: chat) == nil) + #expect(try database.material(for: .test(2)) == material) + } + + @Test("Leaving a conversation removes its envelope") + func deleteConversationClears() throws { + let database = Database.mock + try database.upsertConversation(group(chat)) + try database.store(try ownEnvelope(for: KeyPair.generate()!), for: chat) + try database.deleteConversation(conversationID: chat) + #expect(try database.material(for: chat) == nil) + } + + @Test("A group feed that reports a group departed removes its envelope") + func departedGroupClears() throws { + let database = Database.mock + try database.upsertConversation(group(chat)) + try database.store(try ownEnvelope(for: KeyPair.generate()!), for: chat) + try database.replaceGroupFeed([], departed: [chat]) + #expect(try database.material(for: chat) == nil) + } + + @Test("A group feed that keeps the group keeps its envelope") + func keptGroupKeepsEnvelope() throws { + let database = Database.mock + let material = try ownEnvelope(for: KeyPair.generate()!) + try database.store(material, for: chat) + try database.replaceGroupFeed([group(chat)], departed: []) + #expect(try database.material(for: chat) == material) + } +} + +@MainActor +@Suite("Payment request text offline") +struct EncryptedPaymentRequestOfflineTests { + + private struct Offline: Error {} + + private let creator = KeyPair.generate()! + private let copy = PaymentRequestCopy(displayCurrency: .usd, rates: [:]) + + /// A keyring over the database's envelope store whose network is down. + private func keyring(_ database: Database, online server: GroupKeyMaterial?) -> ChatKeyring { + let envelopes = GroupKeyEnvelopes( + sealer: sealer, + owner: creator, + selfUserID: selfID, + setEnvelope: { _, _ in throw Offline() }, + getEnvelope: { _ in + guard let server else { throw Offline() } + return server + }, + resolveKey: { _ in throw Offline() }, + envelopeStore: database + ) + return ChatKeyring(owner: creator, selfUserID: selfID, resolveKey: { _ in throw Offline() }, groupKey: { id, creator in + try await envelopes.groupKey(for: id, creator: creator) + }) + } + + private func openingSeal(_ keyring: ChatKeyring) -> PaymentRequests.OpeningSeal { + { _ in + switch await keyring.opener(for: group(chat)) { + case .seal(let seal): seal + case .awaitingKey, .unsupported, .unusableKey: nil + } + } + } + + private func makeRequest(sealedBy seal: ChatSeal) throws -> GroupPaymentRequest { + GroupPaymentRequest( + id: PaymentRequestID(data: Data([1])), + creator: selfID, + createdAt: Date(timeIntervalSince1970: 1_800_000_000), + status: .open, + closedAt: nil, + version: 1, + chatID: chat, + split: .init( + text: try seal.sealRequestText(title: "Dinner", description: "Friday"), + currency: "usd", + endsAt: Date(timeIntervalSince1970: 1_800_086_400), + shares: [.init(userID: selfID, nativeAmount: 10, paidAt: Date(timeIntervalSince1970: 1_800_000_000), settlement: .creator)] + ) + ) + } + + @Test("After a restart with the network down, an encrypted request's text opens from the stored envelope") + func opensOffline() async throws { + let database = Database.mock + let server = try ownEnvelope(for: creator) + + // First launch, online: the seal is fetched and its envelope stored. + let online = PaymentRequests(getChatPage: { _, _, _ in [] }, getRequest: { _ in throw ErrorGetPaymentRequest.unknown }, openingSeal: openingSeal(keyring(database, online: server)), database: database) + await online.loadSeal(for: chat) + let seal = try #require(online.seal(for: chat)) + let request = try makeRequest(sealedBy: seal) + online.ingest([request]) + if case .card = online.cardState(for: request.id, in: chat, sentBy: selfID, copy: copy, now: Date(timeIntervalSince1970: 1_800_000_100)) {} else { + Issue.record("the online card did not draw") + } + + // Restart, offline: a fresh keyring, a fresh store, no server. + let restarted = PaymentRequests(getChatPage: { _, _, _ in throw ErrorGetChatPaymentRequests.unknown }, getRequest: { _ in throw ErrorGetPaymentRequest.unknown }, openingSeal: openingSeal(keyring(database, online: nil)), database: database) + await restarted.refreshChat(chat) + await restarted.loadSeal(for: chat) + #expect(restarted.text(of: request)?.title == "Dinner") + #expect(restarted.text(of: request)?.description == "Friday") + if case .card = restarted.cardState(for: request.id, in: chat, sentBy: selfID, copy: copy, now: Date(timeIntervalSince1970: 1_800_000_100)) {} else { + Issue.record("the restarted card did not draw") + } + } + + @Test("Without a stored envelope the same restart draws the card as loading") + func loadingWithoutEnvelope() async throws { + let database = Database.mock + let server = try ownEnvelope(for: creator) + let online = keyring(database, online: server) + let seal = try #require(await openingSeal(online)(chat)) + let request = try makeRequest(sealedBy: seal) + + let fresh = Database.mock + let restarted = PaymentRequests(getChatPage: { _, _, _ in throw ErrorGetChatPaymentRequests.unknown }, getRequest: { _ in throw ErrorGetPaymentRequest.unknown }, openingSeal: openingSeal(keyring(fresh, online: nil)), database: fresh) + restarted.ingest([request]) + await restarted.loadSeal(for: chat) + #expect(restarted.cardState(for: request.id, in: chat, sentBy: selfID, copy: copy, now: Date(timeIntervalSince1970: 1_800_000_100)) == .loading) + } +} From 2263b80bce7edfaba58e8b9cacd841dd49f88621 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 21:23:50 -0400 Subject: [PATCH 3/3] fix(payments): drop a conversation's payment requests when it is removed --- .../Controllers/ConversationController.swift | 1 + .../PaymentRequest/PaymentRequests.swift | 8 ++ .../Database+Conversations.swift | 6 ++ .../PaymentRequestRemovalTests.swift | 95 +++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 FlipcashTests/PaymentRequest/PaymentRequestRemovalTests.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index fc5060d25..2aa6233f4 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1200,6 +1200,7 @@ final class ConversationController { private func applyGroupFeed(_ groups: [Conversation]) { let departed = store.setGroupFeed(groups) reconcileHidden() + paymentRequests?.forget(conversations: departed) persist(operation: "replace-group-feed") { try database.replaceGroupFeed(groups.map(withStoreMembers), departed: departed) } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 98ddccc47..23e190473 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift @@ -158,6 +158,14 @@ final class PaymentRequests { GroupPaymentRequest.deckOrder(requests.values.filter { $0.chatID == conversationID }, viewer: viewer, at: now) } + /// Drops the held requests and chat seals of conversations the user has left, so a card for a + /// departed chat can't redraw. The database rows go with the conversation's own delete. + func forget(conversations departed: Set) { + guard !departed.isEmpty else { return } + requests = requests.filter { entry in entry.value.chatID.map { !departed.contains($0) } ?? true } + for id in departed { seals[id] = nil } + } + /// Merges fetched copies into the store and writes them through to the database. func ingest(_ incoming: [GroupPaymentRequest]) { merge(incoming) diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index d6566451e..256837fca 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -340,6 +340,7 @@ nonisolated extension Database { let c = ConversationTable() let m = ConversationMemberTable() let k = GroupKeyEnvelopeTable() + let p = PaymentRequestTable() let ids = conversations.map(\.id.data) // IMMEDIATE: this transaction reads before it writes. A DEFERRED one that reads first fails // the write with SQLITE_BUSY at once when another writer holds the lock, without consulting @@ -359,6 +360,7 @@ nonisolated extension Database { try writer.run(c.table.filter(doomed.contains(c.id)).delete()) try writer.run(m.table.filter(doomed.contains(m.conversationId)).delete()) try writer.run(k.table.filter(doomed.contains(k.conversationID)).delete()) + try writer.run(p.table.filter(doomed.contains(p.conversationID)).delete()) } for conversation in conversations where conversation.type == type { try writeConversation(conversation) @@ -379,10 +381,12 @@ nonisolated extension Database { let m = ConversationMemberTable() let g = GroupMembershipTable() let k = GroupKeyEnvelopeTable() + let p = PaymentRequestTable() try writer.transaction(.immediate) { let doomed = departed.map(\.data) if !doomed.isEmpty { try writer.run(k.table.filter(doomed.contains(k.conversationID)).delete()) + try writer.run(p.table.filter(doomed.contains(p.conversationID)).delete()) try writer.run(c.table.filter(doomed.contains(c.id)).delete()) try writer.run(m.table.filter(doomed.contains(m.conversationId)).delete()) try writer.run(g.table.filter(doomed.contains(g.conversationId)).delete()) @@ -481,10 +485,12 @@ nonisolated extension Database { let c = ConversationTable() let m = ConversationMemberTable() let k = GroupKeyEnvelopeTable() + let p = PaymentRequestTable() try writer.transaction(.immediate) { try writer.run(c.table.filter(c.id == conversationID.data).delete()) try writer.run(m.table.filter(m.conversationId == conversationID.data).delete()) try writer.run(k.table.filter(k.conversationID == conversationID.data).delete()) + try writer.run(p.table.filter(p.conversationID == conversationID.data).delete()) } } } diff --git a/FlipcashTests/PaymentRequest/PaymentRequestRemovalTests.swift b/FlipcashTests/PaymentRequest/PaymentRequestRemovalTests.swift new file mode 100644 index 000000000..926a13748 --- /dev/null +++ b/FlipcashTests/PaymentRequest/PaymentRequestRemovalTests.swift @@ -0,0 +1,95 @@ +// +// PaymentRequestRemovalTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import FlipcashCore +import FlipcashStore +@testable import Flipcash + +private let leaving = ConversationID.test(1) +private let staying = ConversationID.test(2) +private let leavingRequest = PaymentRequestID(data: Data([1])) +private let stayingRequest = PaymentRequestID(data: Data([2])) +private let owner = UUID() + +private func conversation(_ id: ConversationID, type: ConversationType) -> Conversation { + Conversation(id: id, members: [], lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 100), type: type, creator: owner) +} + +private func request(_ id: PaymentRequestID, in chat: ConversationID) -> GroupPaymentRequest { + GroupPaymentRequest( + id: id, creator: owner, createdAt: nil, status: .open, closedAt: nil, version: 1, chatID: chat, + split: .init(text: .plaintext(title: "Dinner", description: ""), currency: "usd", endsAt: nil, shares: []) + ) +} + +/// A database holding one request in each of two conversations of `type`. +private func seeded(type: ConversationType) throws -> Database { + let database = Database.mock + try database.upsertConversation(conversation(leaving, type: type)) + try database.upsertConversation(conversation(staying, type: type)) + try database.upsertPaymentRequests([request(leavingRequest, in: leaving), request(stayingRequest, in: staying)]) + return database +} + +private func expectOnlyStayingRemains(_ database: Database) throws { + #expect(try database.paymentRequests(in: leaving).isEmpty) + #expect(try database.paymentRequest(id: leavingRequest) == nil) + #expect(try database.paymentRequests(in: staying).map(\.id) == [stayingRequest]) +} + +@Suite("Payment requests leave with their conversation") +struct DatabasePaymentRequestRemovalTests { + + @Test("Leaving a conversation deletes its payment requests") + func deleteConversation() throws { + let database = try seeded(type: .group) + try database.deleteConversation(conversationID: leaving) + try expectOnlyStayingRemains(database) + } + + @Test("A group feed that reports a group departed deletes its payment requests") + func departedGroup() throws { + let database = try seeded(type: .group) + try database.replaceGroupFeed([conversation(staying, type: .group)], departed: [leaving]) + try expectOnlyStayingRemains(database) + } + + @Test("A feed that drops a conversation deletes its payment requests") + func droppedFromFeed() throws { + let database = try seeded(type: .contactDm) + try database.replaceConversationFeed([conversation(staying, type: .contactDm)], type: .contactDm) + try expectOnlyStayingRemains(database) + } + + @Test("A group feed that keeps the group keeps its payment requests") + func keptGroup() throws { + let database = try seeded(type: .group) + try database.replaceGroupFeed([conversation(leaving, type: .group), conversation(staying, type: .group)], departed: []) + #expect(try database.paymentRequests(in: leaving).map(\.id) == [leavingRequest]) + #expect(try database.paymentRequests(in: staying).map(\.id) == [stayingRequest]) + } +} + +@MainActor +@Suite("Held payment requests of a departed chat") +struct PaymentRequestsForgetTests { + + @Test("Forgetting a chat drops its held requests and keeps the others") + func forgets() { + let store = PaymentRequests( + getChatPage: { _, _, _ in throw ErrorGetChatPaymentRequests.unknown }, + getRequest: { _ in throw ErrorGetPaymentRequest.unknown }, + database: Database.mock + ) + store.ingest([request(leavingRequest, in: leaving), request(stayingRequest, in: staying)]) + store.forget(conversations: [leaving]) + #expect(store.request(leavingRequest) == nil) + #expect(store.request(stayingRequest) != nil) + } +}