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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -1095,19 +1106,21 @@ 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())
return@launch
}
}

// 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -167,18 +168,25 @@ 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,
verifiedFiat: VerifiedFiat,
token: Token,
source: AccountCluster,
origin: TipOrigin,
action: TipAction,
): Result<ChatId?> {
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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <T> TippingCoordinator.privateStateFlow(name: String): MutableStateFlow<T> {
val field = TippingCoordinator::class.java.getDeclaredField(name)
field.isAccessible = true
return field.get(this) as MutableStateFlow<T>
}

@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<Byte>(4, 5, 6)

val mint = Mint.usdf
val token = mockk<Token>(relaxed = true) {
every { address } returns mint
}
val rate = Rate.oneToOne
val amount = Fiat(5.0, CurrencyCode.USD)
val owner = mockk<AccountCluster>(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<ChatId?>(null)

val coordinator = buildCoordinator()

// Selects the tip card the same way onCardResolved would, minus the native-dependent
// OpenCodePayload construction resolveTipCard performs.
coordinator.privateStateFlow<ID?>("_userId").value = userId
coordinator.privateStateFlow<UserProfile?>("_recipient").value = profile("Bob")
coordinator.privateStateFlow<Boolean>("_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,
)
}
}
}
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
}
Original file line number Diff line number Diff line change
@@ -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() }
}
Loading