Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 21 additions & 14 deletions Flipcash/Core/Screens/Send/SendAmountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
}
Expand Down
2 changes: 1 addition & 1 deletion FlipcashAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand All @@ -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())

Expand All @@ -53,6 +54,7 @@ struct ChatPaymentMetadataTests {
return
}
#expect(payment.location == .chat)
#expect(payment.action == .send)
}

@Test("Each variant exposes its chat id uniformly")
Expand All @@ -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)
}
}
19 changes: 19 additions & 0 deletions FlipcashCore/Tests/FlipcashCoreTests/IntentTransferTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()!,
Expand Down
15 changes: 10 additions & 5 deletions FlipcashTests/SendAmountViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand Down
Loading