From b12b972f903d36a4e4a4f6f0e3d2004de8f1bcd9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 14:41:26 -0400 Subject: [PATCH 1/2] feat(chat): require an idempotency key on StartChat flipcash2 0.8.0 adds a required idempotency_key field to StartChatRequest: a retry with the same key returns the original chat (result OK) instead of creating a duplicate, even if the group title/picture/rules differ on the retry. Thread a UUID through ChatService.startChat and FlipClient.startChat as a required parameter, encoded the same way clientMessageID already is for SendMessage. The caller must mint the key once, where the user's intent to create the chat originates, and reuse it across retries of that attempt. There is no call site yet to wire that reuse into, so this scaffolds the plumbing without inventing one. Built against the local flipcash2-client-protocol override (FLIPCASH_PROTO_LOCAL); no published release exists at this contract version yet. --- .../FlipcashCore/Clients/Flip API/FlipClient+Chat.swift | 8 ++++++-- .../Clients/Flip API/Services/ChatService.swift | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index f140af3e7..90fe4cdde 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -55,9 +55,13 @@ extension FlipClient { /// Starts a new group chat and returns its metadata on success. `rules` gates who may read/join /// and who may send; `nil` leaves the chat unrestricted. - public func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?) async throws -> Conversation { + /// + /// `idempotencyKey` must be minted by the caller where the user's intent to create the chat + /// originates (not here) and reused for every retry of that same attempt — see + /// `ChatService.startChat`. A retry with the same key returns the original chat. + public func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, idempotencyKey: UUID) async throws -> Conversation { try await withCheckedThrowingContinuation { c in - chatService.startChat(owner: owner, title: title, pictureBlobID: pictureBlobID, rules: rules) { c.resume(with: $0) } + chatService.startChat(owner: owner, title: title, pictureBlobID: pictureBlobID, rules: rules, idempotencyKey: idempotencyKey) { c.resume(with: $0) } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 48733a932..af8414162 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -128,7 +128,13 @@ final class ChatService: Sendable { /// restrictions. On `.titleModerated` the server also reports which category flagged the /// title; `ErrorStartChat.titleModerated` carries it through so callers can say why, not just /// that the title was rejected. - func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, completion: @Sendable @escaping (Result) -> Void) { + /// + /// `idempotencyKey` is required by the server: caller and key together identify the chat being + /// created, so a retry with the same key returns the original chat (result `.ok`) rather than + /// creating a duplicate, even if `title`/`pictureBlobID`/`rules` differ on the retry. Mint it once + /// where the user's intent to create the chat originates and reuse it for every retry of that same + /// attempt — never generate a fresh key per call, or retries lose their idempotency. + func startChat(owner: KeyPair, title: String, pictureBlobID: BlobID?, rules: ConversationRules?, idempotencyKey: UUID, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Chat_V1_StartChatRequest.with { $0.group = .with { $0.title = title @@ -139,6 +145,7 @@ final class ChatService: Sendable { $0.rules = rules.proto } } + $0.idempotencyKey = .with { $0.value = idempotencyKey.data } $0.auth = owner.authFor(message: $0) } From 9281d72539aaa565acac4258ff0dedccb2fb9bb2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 14:45:58 -0400 Subject: [PATCH 2/2] chore(deps): pin flipcash2-client-protocol to 0.8.0 StartChat needs the idempotency_key field that 0.8.0 adds. Blocked until that tag exists for SPM to resolve. --- FlipcashAPI/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index a9f19a4e9..d2cecb5da 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.4.0" - case .flipcash2: return "0.7.0" + case .flipcash2: return "0.8.0" } }