From 5cda0858c52c8d8c05b7e6b86be87a8e7caacb58 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 10 Sep 2026 16:18:13 -0400 Subject: [PATCH] feat(chat): declare the payment action on tip DM payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `flipcash2-client-protocol` 0.5.0 adds `action` to `intent.v1.ChatMetadata.TipDmPayment`, and the server prefers it over `location` when it resolves the verb the recipient sees (`SENT` vs `TIPPED`). Left unset it reads as `DEFAULT`, which falls back to the location — and since `TIPCARD` is also the zero value, an unset action on an unset location resolves to a tip. So the field is not optional on the path that opens a tip DM: the server denies that intent unless it resolves to a tip. A tip card payment and the payment that opens the DM send `TIP`; Send Cash inside an already-initialized tip DM sends `SEND`. `TipDmAction` carries only those two cases, so `DEFAULT` is unrepresentable rather than merely avoided. `location` keeps its current values on every path. A server predating the field reads `location` alone, and because `action` agrees with what `location` already implied, both server versions resolve the same verb through the rollout. Analytics on the send screen now reads the same metadata the wire carries instead of re-deriving tip-ness, so the event and the verb cannot drift. --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Screens/Send/SendAmountViewModel.swift | 35 +++++++------ FlipcashAPI/Package.swift | 2 +- .../Conversation/ChatPaymentMetadata.swift | 49 +++++++++++++++++-- .../ChatPaymentMetadataTests.swift | 25 ++++++++-- .../IntentTransferTests.swift | 19 +++++++ FlipcashTests/SendAmountViewModelTests.swift | 15 ++++-- 7 files changed, 118 insertions(+), 31 deletions(-) diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 11431824b..501aa3106 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "27e3f09a82f6fbd41c03026ee738f943f4ed465e", - "version" : "0.4.0" + "revision" : "524cfbee86388ee0b13078d6159e4d2961fe130a", + "version" : "0.5.0" } }, { diff --git a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift index 179df5747..a7ce85936 100644 --- a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift +++ b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift @@ -83,12 +83,17 @@ final class SendAmountViewModel { /// Where this send reports as coming from, which is not always the surface /// that composed it. /// - /// `CHAT` means "sent from a thread that already exists": the server rejects - /// it with `tip dm has not been initialized` when no DM is there yet. The - /// payment that opens the DM therefore always reports as `TIPCARD`, however - /// it was composed — a scanned card, or the chat the username lookup pushes - /// before the thread is real. Matches Android's rule, which reads the same - /// flag off a bottom bar still showing "Send a Tip". + /// The payment that opens the DM always reports as `TIPCARD`, however it was + /// composed — a scanned card, or the chat the username lookup pushes before + /// the thread is real. Matches Android's rule, which reads the same flag off + /// a bottom bar still showing "Send a Tip". + /// + /// What the server actually enforces is the verb, not this field: a send is + /// denied with `tip dm has not been initialized` unless the chat already + /// exists, and only a tip may open one. Since `action` is derived from this + /// value, keeping it `TIPCARD` here is what makes the opening payment + /// resolve to a tip on a server that reads `action` and on one that predates + /// it alike. private var effectiveTipOrigin: TipOrigin? { guard case .tip(let recipient) = target else { return nil } return opensTipDM ? .tipcard : recipient.origin @@ -351,19 +356,19 @@ final class SendAmountViewModel { return .failed } - // Only a tip card payment is a tip — the same line the activity feed - // draws, from `ChatMetadata.TipDmPayment.Location`. Both the scanned - // tipcard flow and the Send Cash action inside a tip thread submit - // here, and the latter reports as a plain cash send. - let isTip = effectiveTipOrigin == .tipcard - let transferEvent: Analytics.TransferEvent = isTip ? .sentTip : .sentCash + // Both the scanned tipcard flow and the Send Cash action inside a tip + // thread submit here; `chat` is the single source for whether this + // reports as a tip, so the wire `action` and the analytics event + // never drift apart. + let chat = chatPaymentMetadata() + let transferEvent: Analytics.TransferEvent = (chat?.isTip ?? false) ? .sentTip : .sentCash do { try await sender.send( amount: amountToSend, verifiedState: pinnedState, to: recipient, - chat: chatPaymentMetadata() + chat: chat ) Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: true, error: nil) return .success @@ -392,9 +397,11 @@ final class SendAmountViewModel { destinationPhoneE164: contact.phoneE164 ) case .tip(let recipient): + let origin = effectiveTipOrigin ?? recipient.origin return .tipDm( chatID: .tipDm(between: session.userID, and: recipient.userID), - origin: effectiveTipOrigin ?? recipient.origin + origin: origin, + action: origin == .tipcard ? .tip : .send ) } } diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 9babd2a7a..075cfa234 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -35,7 +35,7 @@ let contractDependencies: [Package.Dependency] = protoLocalRoot.map { root in ] } ?? [ .package(url: "https://github.com/code-payments/ocp-client-protocol", exact: "0.3.0"), - .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.4.0"), + .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.5.0"), ] let package = Package( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift index 042f41977..f9ef1f5fb 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatPaymentMetadata.swift @@ -16,18 +16,30 @@ import SwiftProtobuf public enum ChatPaymentMetadata: Sendable { case contactDm(chatID: ConversationID, sourcePhoneE164: String, destinationPhoneE164: String) - case tipDm(chatID: ConversationID, origin: TipOrigin) + case tipDm(chatID: ConversationID, origin: TipOrigin, action: TipDmAction) /// The DM chat this payment posts into. public var chatID: ConversationID { switch self { case .contactDm(let chatID, _, _): return chatID - case .tipDm(let chatID, _): + case .tipDm(let chatID, _, _): return chatID } } + /// Whether this payment reports as a tip. Mirrors the `action` serialized + /// below, so a caller reporting analytics reads the same value the wire + /// carries instead of re-deriving it. + public var isTip: Bool { + switch self { + case .contactDm: + return false + case .tipDm(_, _, let action): + return action == .tip + } + } + /// Serialized `flipcash.intent.v1.AppMetadata` for SubmitIntent's /// `Metadata.app_metadata` value. public func serializedAppMetadata() throws -> Data { @@ -40,8 +52,11 @@ public enum ChatPaymentMetadata: Sendable { $0.source = .with { $0.value = sourcePhoneE164 } $0.destination = .with { $0.value = destinationPhoneE164 } } - case .tipDm(_, let origin): - $0.tipDmPayment = .with { $0.location = origin.proto } + case .tipDm(_, let origin, let action): + $0.tipDmPayment = .with { + $0.location = origin.proto + $0.action = action.proto + } } } }.serializedData() @@ -66,3 +81,29 @@ public enum TipOrigin: Sendable { } } } + +/// The verb a tip DM payment reports to the server, alongside `TipOrigin`. +/// +/// The proto's `Action.default` means "infer from location," which only +/// exists so the server can stay compatible with clients built before this +/// field shipped. This client has no such clients to be compatible with, so +/// `.default` has no local representation here: every `TipDmPayment` this +/// client builds sets `action` explicitly. That matters because proto3 gives +/// `Location` a zero value too (`TIPCARD`), so an unset `action` alongside a +/// `TIPCARD` location would be indistinguishable on the wire from a client +/// that deliberately declared a tip — `DEFAULT` and `TIPCARD` are both 0. +public enum TipDmAction: Sendable, CaseIterable { + + /// A Send Cash payment inside an already-initialized tip DM. + case send + + /// A payment from a tip card, or the payment that opens the tip DM. + case tip + + var proto: Flipcash_Intent_V1_ChatMetadata.TipDmPayment.Action { + switch self { + case .send: return .send + case .tip: return .tip + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift index 9493dd59e..478f4e9c2 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPaymentMetadataTests.swift @@ -27,9 +27,9 @@ struct ChatPaymentMetadataTests { #expect(payment.destination.value == "+15551230002") } - @Test("Tip DM payments carry the chat id and the tipcard origin") + @Test("Tip DM payments carry the chat id, the tipcard origin, and the tip action") func tipDmTipcardSerialization() throws { - let metadata = ChatPaymentMetadata.tipDm(chatID: chatID, origin: .tipcard) + let metadata = ChatPaymentMetadata.tipDm(chatID: chatID, origin: .tipcard, action: .tip) let decoded = try Flipcash_Intent_V1_AppMetadata(serializedBytes: metadata.serializedAppMetadata()) @@ -39,11 +39,12 @@ struct ChatPaymentMetadataTests { return } #expect(payment.location == .tipcard) + #expect(payment.action == .tip) } - @Test("Tip DM payments carry the chat origin when sent from a chat") + @Test("Tip DM payments carry the chat origin and send action when sent from a chat") func tipDmChatSerialization() throws { - let metadata = ChatPaymentMetadata.tipDm(chatID: chatID, origin: .chat) + let metadata = ChatPaymentMetadata.tipDm(chatID: chatID, origin: .chat, action: .send) let decoded = try Flipcash_Intent_V1_AppMetadata(serializedBytes: metadata.serializedAppMetadata()) @@ -53,6 +54,7 @@ struct ChatPaymentMetadataTests { return } #expect(payment.location == .chat) + #expect(payment.action == .send) } @Test("Each variant exposes its chat id uniformly") @@ -62,9 +64,22 @@ struct ChatPaymentMetadataTests { sourcePhoneE164: "+15551230001", destinationPhoneE164: "+15551230002" ) - let tip = ChatPaymentMetadata.tipDm(chatID: chatID, origin: .tipcard) + let tip = ChatPaymentMetadata.tipDm(chatID: chatID, origin: .tipcard, action: .tip) #expect(contact.chatID == chatID) #expect(tip.chatID == chatID) } + + @Test("The local action type never serializes the proto's DEFAULT case", arguments: TipDmAction.allCases) + func actionNeverSerializesDefault(action: TipDmAction) throws { + let metadata = ChatPaymentMetadata.tipDm(chatID: chatID, origin: .tipcard, action: action) + + let decoded = try Flipcash_Intent_V1_AppMetadata(serializedBytes: metadata.serializedAppMetadata()) + + guard case .tipDmPayment(let payment) = decoded.chat.type else { + Issue.record("Expected tipDmPayment, got \(String(describing: decoded.chat.type))") + return + } + #expect(payment.action != .default) + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/IntentTransferTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/IntentTransferTests.swift index 1561a3512..4550d1d31 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/IntentTransferTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/IntentTransferTests.swift @@ -121,6 +121,25 @@ extension IntentTransferTests { #expect(chatMetadata.contactDmPayment.destination.value == "+14155550101") } + @Test("Metadata carries serialized tip DM app metadata that round-trips location and action") + func metadataCarriesTipDmAppMetadata() throws { + let chatID = ConversationID.tipDm(between: UUID(), and: UUID()) + let chat = ChatPaymentMetadata.tipDm(chatID: chatID, origin: .chat, action: .send) + let intent = try makeIntent(appMetadata: chat.serializedAppMetadata()) + + let metadata = intent.metadata() + #expect(metadata.hasAppMetadata) + + let decoded = try Flipcash_Intent_V1_AppMetadata(serializedBytes: metadata.appMetadata.value) + guard case .chat(let chatMetadata)? = decoded.domain else { + Issue.record("Expected chat domain metadata") + return + } + #expect(chatMetadata.chatID.value == chatID.data) + #expect(chatMetadata.tipDmPayment.location == .chat) + #expect(chatMetadata.tipDmPayment.action == .send) + } + private func makeIntent( sourceCluster: AccountCluster = .mock, destination: PublicKey = .generate()!, diff --git a/FlipcashTests/SendAmountViewModelTests.swift b/FlipcashTests/SendAmountViewModelTests.swift index 27b387be0..94e247564 100644 --- a/FlipcashTests/SendAmountViewModelTests.swift +++ b/FlipcashTests/SendAmountViewModelTests.swift @@ -598,12 +598,13 @@ struct SendAmountViewModelTests { #expect(mock.resolveUserIDCalls == [recipientID]) #expect(mock.resolveContactCalls.isEmpty) let chat = try #require(mock.sendCalls.first?.chat) - guard case .tipDm(let chatID, let origin) = chat else { + guard case .tipDm(let chatID, let origin, let action) = chat else { Issue.record("Expected tipDm metadata, got \(chat)") return } #expect(chatID == ConversationID.tipDm(between: container.session.userID, and: recipientID)) #expect(origin == .tipcard) + #expect(action == .tip) } @Test("The tip that opens the DM reports as tipcard even when it's composed in a chat") @@ -622,13 +623,16 @@ struct SendAmountViewModelTests { #expect(outcome == .success) let chat = try #require(mock.sendCalls.first?.chat) - guard case .tipDm(_, let origin) = chat else { + guard case .tipDm(_, let origin, let action) = chat else { Issue.record("Expected tipDm metadata, got \(chat)") return } - // `CHAT` here is what the server rejects with "tip dm has not been - // initialized" — there is no thread yet for this payment to be sent from. + // A send is what the server rejects with "tip dm has not been + // initialized" — there is no thread yet for this payment to be sent + // from, so the opening payment has to resolve to a tip. #expect(origin == .tipcard) + // The DM-opening payment is always a tip, however it was composed. + #expect(action == .tip) } @Test("Once the DM exists, an in-chat send still reports as chat") @@ -646,11 +650,12 @@ struct SendAmountViewModelTests { #expect(outcome == .success) let chat = try #require(mock.sendCalls.first?.chat) - guard case .tipDm(_, let origin) = chat else { + guard case .tipDm(_, let origin, let action) = chat else { Issue.record("Expected tipDm metadata, got \(chat)") return } #expect(origin == .chat) + #expect(action == .send) } @Test("A tip below the server minimum is blocked before submission")