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/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/Screens/PaymentRequest/PaymentRequests.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequests.swift index 6db6dea94..23e190473 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,55 @@ final class PaymentRequests { GroupPaymentRequest.deckOrder(requests.values.filter { $0.chatID == conversationID }, viewer: viewer, at: now) } - /// Merges fetched copies into the store. + /// 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) + 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 +214,6 @@ final class PaymentRequests { } else { requests[request.id] = request } - unavailable.remove(request.id) } } @@ -181,6 +236,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 +260,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 +314,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..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, @@ -1032,7 +1036,8 @@ final class SessionContainer { pageSize: pageSize, pagingToken: pagingToken ) - } + }, + database: database ) conversationController.paymentRequests = self.paymentRequests self.rosterSearch = mentionSearch 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/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+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 698a711fe..256837fca 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -339,6 +339,8 @@ nonisolated extension Database { try write { writer in 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 @@ -357,6 +359,8 @@ 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()) + try writer.run(p.table.filter(doomed.contains(p.conversationID)).delete()) } for conversation in conversations where conversation.type == type { try writeConversation(conversation) @@ -376,9 +380,13 @@ nonisolated extension Database { let c = ConversationTable() 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()) @@ -469,16 +477,20 @@ 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() + 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/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+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..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 = 62 + 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 a425b4db6..a4c3d25ec 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -142,6 +142,38 @@ 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") +} + +// 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" @@ -558,6 +590,30 @@ 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 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) + } +} 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) + } +} 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) + } +}