diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 0342718211..d935efbb74 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -32,6 +32,7 @@ import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.messenger.R +import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId @@ -1075,12 +1076,22 @@ internal class ChatViewModel @Inject constructor( // sends one. Every later send comes from the money button beside a composer // that only exists once the thread is unlocked, and stays a plain send. // - // `TIPCARD` is how a tip is asked for: `TipDmPayment.Location` has two values, - // and the server reads them as the verb ("Tipped" vs "Sent") rather than as a - // place. Sending `CHAT` here would title the payment "Sent" in the recipient's - // activity feed, under a button that promised a tip. - val isTip = stateFlow.value.chatType == ChatType.TIP_DM && + // This is the one place that decides it, and both `TipDmPayment.action` (the + // verb the server renders — "Tipped" vs "Sent") and the analytics event below + // are read off this single value rather than each re-deriving "is this a tip." + // `origin`/`location` keeps being computed off the same condition as before — + // this change doesn't touch what byte it sends. The server reads it in exactly + // one place, as the fallback when `action` is `DEFAULT`, so leaving it alone is + // what keeps a server that predates `action` resolving the same verb as one + // that reads it. + val tipAction = if ( + stateFlow.value.chatType == ChatType.TIP_DM && !stateFlow.value.typingConstraints.enabled + ) { + TipAction.TIP + } else { + TipAction.SEND + } val result = when (val participant = stateFlow.value.participant) { is ChatParticipant.Contact -> contactPaymentDelegate.send( @@ -1095,7 +1106,8 @@ internal class ChatViewModel @Inject constructor( verifiedFiat = verifiedFiat, token = token, source = source, - origin = if (isTip) TipOrigin.TIPCARD else TipOrigin.CHAT, + origin = if (tipAction == TipAction.TIP) TipOrigin.TIPCARD else TipOrigin.CHAT, + action = tipAction, ) null -> { dispatchEvent(Event.SendStateUpdated()) @@ -1103,11 +1115,12 @@ internal class ChatViewModel @Inject constructor( } } - // Report what was sent, on the same line `TipDmPayment.Location` draws: the - // tip call to action above is a tip, and every other send from this screen — - // contact DM or unlocked tip DM — is a plain cash send. + // Report what was sent — the tip call to action above is a tip, and every + // other send from this screen (contact DM or unlocked tip DM) is a plain cash + // send. Same `tipAction` the wire `action` above was set from, not a second + // "is this a tip" check that could drift from it. val transferEvent = - if (isTip) Analytics.Transfer.SentTip else Analytics.Transfer.SentCash + if (tipAction == TipAction.TIP) Analytics.Transfer.SentTip else Analytics.Transfer.SentCash result.onSuccess { dispatchEvent(Event.SendStateUpdated(success = true)) diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt index be18c8fe3e..a7bb4b5ab2 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt @@ -3,6 +3,7 @@ package com.flipcash.shared.payments import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile import com.flipcash.services.models.buildTipDmPaymentMetadata @@ -167,8 +168,10 @@ class TipPaymentDelegate @Inject constructor( * Sends [verifiedFiat] of [token] from [source] to the user identified by [userId] as a tip DM: * derives the canonical tip chat, resolves the recipient's on-chain owner, attaches tip-DM app * metadata, transfers, debits the local balance, and syncs the chat feed. [origin] records - * where the tip was initiated (a tip card vs. an in-chat send). Returns the canonical tip - * [ChatId] (for message reload / navigation), or null if it couldn't be derived. + * where the tip was initiated (a tip card vs. an in-chat send); [action] is the verb the + * server renders for it ("Tipped" vs "Sent") and is always explicit — see [TipAction]. Returns + * the canonical tip [ChatId] (for message reload / navigation), or null if it couldn't be + * derived. */ suspend fun send( userId: ID, @@ -176,9 +179,14 @@ class TipPaymentDelegate @Inject constructor( token: Token, source: AccountCluster, origin: TipOrigin, + action: TipAction, ): Result { val canonicalChatId = chatCoordinator.generateChatId(userId = userId).getOrNull() - val appMetadataBytes = buildTipDmPaymentMetadata(chatId = canonicalChatId, origin = origin) + val appMetadataBytes = buildTipDmPaymentMetadata( + chatId = canonicalChatId, + origin = origin, + action = action, + ) return resolverController.resolve(userId = userId) .mapCatching { destination -> diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt index 32f88be7ba..ed01ba87dd 100644 --- a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt +++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt @@ -13,6 +13,7 @@ import com.flipcash.app.currency.PreferredCurrencyController import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.TipAction import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager @@ -239,6 +240,8 @@ class TippingCoordinator @Inject constructor( token = token, source = source, origin = TipOrigin.TIPCARD, + // A tip card is always a genuine tip — there is no "send cash" path through it. + action = TipAction.TIP, ).onSuccess { canonicalChatId -> setSendState(LoadingSuccessState(success = true)) delay(400.milliseconds) diff --git a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt index 693bedd71f..bb97e74d67 100644 --- a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt +++ b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt @@ -1,19 +1,35 @@ package com.flipcash.shared.tipping import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.tipping.TipAmount import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.TipAction +import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.payments.TipPaymentDelegate import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper import com.getcode.util.vibration.Vibrator import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -93,4 +109,77 @@ class TippingCoordinatorTest { assertEquals(id, coordinator.currentUserId) } + + /** + * Reaches into [TippingCoordinator]'s private `_userId`/`_recipient`/`_canTip` state to select + * a tip-card recipient without going through [TippingCoordinator.resolveTipCard] — that method + * builds a real scannable [com.getcode.opencode.model.core.OpenCodePayload], which loads the + * native `kikCodes`/`codeScanner` libraries a plain JVM unit test has no access to + * (`UnsatisfiedLinkError`). Only [TippingCoordinator.confirmTip]'s wiring is under test here. + */ + @Suppress("UNCHECKED_CAST") + private fun TippingCoordinator.privateStateFlow(name: String): MutableStateFlow { + val field = TippingCoordinator::class.java.getDeclaredField(name) + field.isAccessible = true + return field.get(this) as MutableStateFlow + } + + @Test + fun `confirmTip sends the tip card as a TIP action`() = runTest { + // A tip card has no "send cash" path — every payment through it is a genuine tip, so + // confirmTip must always report TipAction.TIP on the wire, never SEND. + val userId = listOf(4, 5, 6) + + val mint = Mint.usdf + val token = mockk(relaxed = true) { + every { address } returns mint + } + val rate = Rate.oneToOne + val amount = Fiat(5.0, CurrencyCode.USD) + val owner = mockk(relaxed = true) { + every { withTimelockForToken(token) } returns this + } + val verifiedFiat = VerifiedFiat(LocalFiat.Zero, null) + + every { exchange.preferredRate } returns rate + // selectedToken is a plain `val` built once at construction from these two calls, not a + // lazily-recomputed property — stubbing them has to happen before TippingCoordinator is + // built, or the coordinator captures whatever the mock's default (unstubbed) answer was. + every { tokenCoordinator.observeSelectedTokenMint() } returns flowOf(mint) + every { tokenCoordinator.tokens } returns flowOf(listOf(token)) + every { tokenCoordinator.balanceForToken(token) } returns amount + every { userManager.accountCluster } returns owner + every { tipPaymentDelegate.minimumTipFor(userId, any()) } returns flowOf(null) + coEvery { + verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) + } returns Result.success(verifiedFiat) + coEvery { + tipPaymentDelegate.send(any(), any(), any(), any(), any(), any()) + } returns Result.success(null) + + val coordinator = buildCoordinator() + + // Selects the tip card the same way onCardResolved would, minus the native-dependent + // OpenCodePayload construction resolveTipCard performs. + coordinator.privateStateFlow("_userId").value = userId + coordinator.privateStateFlow("_recipient").value = profile("Bob") + coordinator.privateStateFlow("_canTip").value = true + + coordinator.selectAmount(TipAmount.Custom(amount)) + coordinator.confirmTip() + + // confirmTip fires into the coordinator's own background scope rather than this test's, + // so the send lands asynchronously — coVerify's timeout polls for it instead of asserting + // immediately after the (non-suspend) call above returns. + coVerify(timeout = 5_000) { + tipPaymentDelegate.send( + userId = userId, + verifiedFiat = verifiedFiat, + token = token, + source = owner, + origin = TipOrigin.TIPCARD, + action = TipAction.TIP, + ) + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1822713f3f..9967d745b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.2" # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. ocp-client-protocol = "0.3.0" -flipcash2-client-protocol = "0.4.1" +flipcash2-client-protocol = "0.5.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt index 72b2217807..7bed493244 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt @@ -29,20 +29,36 @@ fun buildDmPaymentMetadata( ).build().toByteArray() } -/** Where in the app a tip DM payment was initiated, reported to the backend. */ +/** Where in the app a tip DM payment was initiated, reported to the backend as `location`. */ enum class TipOrigin { TIPCARD, CHAT } +/** + * The verb the client intends for a tip DM payment, reported to the backend as `action`. + * + * There is no `DEFAULT` case here on purpose. `action` is proto3, so an unset field reads back + * as its zero value — which is `DEFAULT`, the same number as `location`'s zero value (`TIPCARD`). + * That makes an unset `action` alongside a `TIPCARD` location indistinguishable from a client + * deliberately declaring a tip. This client always sets one of the two real actions + * explicitly, so `DEFAULT` never leaves it — a type that cannot express `DEFAULT` is how that + * stays true. + */ +enum class TipAction { SEND, TIP } + /** * Builds the serialized Flipcash `AppMetadata` proto bytes for a tip DM payment. * * Unlike a contact DM payment there is no phone source/destination — a tip DM is - * between two user IDs, which map directly to/from public keys. [origin] records - * where the tip was sent from (a tip card vs. an in-chat send). Returns `null` when + * between two user IDs, which map directly to/from public keys. [origin] records where the tip + * was sent from (a tip card vs. an in-chat send) and drives `location`, which the server reads + * only as the fallback for an unset [action]. [action] is the always-explicit verb, and it is + * what the server resolves the payment to — the title on the sender's activity feed, the message + * injected into the DM, and which validation rules the intent is held to. Returns `null` when * [chatId] is missing so the caller can pass the result through unconditionally. */ fun buildTipDmPaymentMetadata( chatId: ChatId?, origin: TipOrigin, + action: TipAction, ): ByteArray? { if (chatId == null) return null return FlipcashIntentModel.AppMetadata.newBuilder() @@ -59,6 +75,14 @@ fun buildTipDmPaymentMetadata( FlipcashIntentModel.ChatMetadata.TipDmPayment.Location.CHAT } ) + .setAction( + when (action) { + TipAction.SEND -> + FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.SEND + TipAction.TIP -> + FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.TIP + } + ) ) ).build().toByteArray() } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/DmPaymentMetadataTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/DmPaymentMetadataTest.kt new file mode 100644 index 0000000000..0cd0bf2b19 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/DmPaymentMetadataTest.kt @@ -0,0 +1,103 @@ +package com.flipcash.services.models + +import com.codeinc.flipcash.gen.intent.v1.Model as FlipcashIntentModel +import com.flipcash.services.models.chat.ChatId +import com.getcode.utils.toByteString +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * [buildTipDmPaymentMetadata] and [buildDmPaymentMetadata] are the only places that build a + * `ChatMetadata.TipDmPayment` / `ChatMetadata.ContactDmPayment`. Nothing round-tripped either + * through the wire before this, so a mistake in which field gets which value — or in which enum + * constant a [TipOrigin]/[TipAction] maps to — would ship silently. These parse the built bytes + * back into `AppMetadata` and assert on the decoded fields, not just on the builder calls. + */ +class DmPaymentMetadataTest { + + private val chatId = ChatId(bytes(32) { it }) + + @Test + fun `tip DM payment sets location TIPCARD and action TIP for a tip-card send`() { + val bytes = buildTipDmPaymentMetadata( + chatId = chatId, + origin = TipOrigin.TIPCARD, + action = TipAction.TIP, + ) + + val decoded = FlipcashIntentModel.AppMetadata.parseFrom(bytes!!) + + assertEquals( + FlipcashIntentModel.ChatMetadata.TipDmPayment.Location.TIPCARD, + decoded.chat.tipDmPayment.location, + ) + assertEquals( + FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.TIP, + decoded.chat.tipDmPayment.action, + ) + assertNotEquals( + FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.DEFAULT, + decoded.chat.tipDmPayment.action, + ) + assertEquals(chatId.bytes.toByteString(), decoded.chat.chatId.value) + } + + @Test + fun `tip DM payment sets location CHAT and action SEND for an in-chat send`() { + val bytes = buildTipDmPaymentMetadata( + chatId = chatId, + origin = TipOrigin.CHAT, + action = TipAction.SEND, + ) + + val decoded = FlipcashIntentModel.AppMetadata.parseFrom(bytes!!) + + assertEquals( + FlipcashIntentModel.ChatMetadata.TipDmPayment.Location.CHAT, + decoded.chat.tipDmPayment.location, + ) + assertEquals( + FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.SEND, + decoded.chat.tipDmPayment.action, + ) + assertNotEquals( + FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.DEFAULT, + decoded.chat.tipDmPayment.action, + ) + } + + @Test + fun `tip DM payment metadata is null when chatId is missing`() { + assertNull(buildTipDmPaymentMetadata(chatId = null, origin = TipOrigin.TIPCARD, action = TipAction.TIP)) + } + + @Test + fun `contact DM payment round-trips the chat id and both phone numbers`() { + val bytes = buildDmPaymentMetadata( + chatId = chatId, + sourcePhone = "+15550001111", + destinationPhone = "+15550002222", + ) + + val decoded = FlipcashIntentModel.AppMetadata.parseFrom(bytes!!) + + assertEquals(chatId.bytes.toByteString(), decoded.chat.chatId.value) + assertEquals("+15550001111", decoded.chat.contactDmPayment.source.value) + assertEquals("+15550002222", decoded.chat.contactDmPayment.destination.value) + assertEquals( + FlipcashIntentModel.ChatMetadata.TypeCase.CONTACT_DM_PAYMENT, + decoded.chat.typeCase, + ) + } + + @Test + fun `contact DM payment metadata is null when any required field is missing`() { + assertNull(buildDmPaymentMetadata(chatId = null, sourcePhone = "+1", destinationPhone = "+2")) + assertNull(buildDmPaymentMetadata(chatId = chatId, sourcePhone = null, destinationPhone = "+2")) + assertNull(buildDmPaymentMetadata(chatId = chatId, sourcePhone = "+1", destinationPhone = null)) + } + + private fun bytes(size: Int, value: (Int) -> Int) = ByteArray(size) { value(it).toByte() } +}