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
2 changes: 2 additions & 0 deletions apps/flipcash/shared/chat/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ android {
dependencies {
testImplementation(kotlin("test"))
testImplementation(libs.bundles.unit.testing)
testImplementation(libs.robolectric)

implementation(libs.bundles.kotlinx.serialization)

Expand All @@ -18,6 +19,7 @@ dependencies {
implementation(project(":apps:flipcash:shared:persistence:db"))
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":services:flipcash"))
implementation(project(":libs:network:connectivity:public"))
implementation(libs.androidx.lifecycle.process)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.flipcash.services.models.chat.MetadataUpdate
import com.flipcash.services.models.chat.PointerType
import com.flipcash.services.models.chat.TypingNotification
import com.flipcash.services.models.chat.TypingState
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.user.UserManager
import com.getcode.opencode.model.accounts.AccountCluster
import com.getcode.opencode.providers.SessionListener
Expand Down Expand Up @@ -79,6 +80,7 @@ class ChatCoordinator @Inject constructor(
private val networkObserver: NetworkConnectivityListener,
private val notificationManager: NotificationManagerCompat,
private val userManager: UserManager,
private val tokenCoordinator: TokenCoordinator,
private val featureFlags: FeatureFlagController,
) : SessionListener, DefaultLifecycleObserver {

Expand Down Expand Up @@ -518,6 +520,18 @@ class ChatCoordinator @Inject constructor(
}
}

// --- Eagerly update token balance for incoming cash ---

val selfId = userManager.accountId
for (msg in update.newMessages) {
if (msg.senderId == selfId) continue
for (content in msg.content) {
if (content is MessageContent.Cash) {
tokenCoordinator.add(content.mint, content.amount)
}
}
}

// --- Check if unknown chat requires a full feed sync ---

if (lastMsg != null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package com.flipcash.shared.chat

import androidx.core.app.NotificationManagerCompat
import com.flipcash.app.featureflags.FeatureFlagController
import com.flipcash.app.persistence.sources.ChatMemberDataSource
import com.flipcash.app.persistence.sources.ChatMessageDataSource
import com.flipcash.app.persistence.sources.ChatMetadataDataSource
import com.flipcash.app.persistence.sources.ContactDataSource
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.controllers.ChatController
import com.flipcash.services.controllers.ChatMessagingController
import com.flipcash.services.controllers.EventStreamingController
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ChatUpdate
import com.flipcash.services.models.chat.MessageContent
import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Fiat
import com.getcode.solana.keys.Mint
import com.getcode.utils.network.NetworkConnectivityListener
import com.flipcash.services.user.UserManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.time.Instant

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class ChatCoordinatorEagerBalanceTest {

private val selfId = listOf<Byte>(1, 2, 3)
private val otherId = listOf<Byte>(4, 5, 6)
private val chatId = ChatId("aabbccdd")
private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa")

private val chatUpdatesChannel = Channel<ChatUpdate>(capacity = Channel.UNLIMITED)

private lateinit var tokenCoordinator: TokenCoordinator
private lateinit var coordinator: ChatCoordinator

@Before
fun setUp() {
tokenCoordinator = mockk(relaxed = true)
val userManager = mockk<UserManager>(relaxed = true)
every { userManager.accountId } returns selfId
val eventStreamingController = mockk<EventStreamingController>(relaxed = true)
every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow()
every { eventStreamingController.isConnected } returns true
every { eventStreamingController.isStreamActive } returns true

val chatController = mockk<ChatController>(relaxed = true)
coEvery { chatController.getDmChatFeed() } returns Result.failure(RuntimeException("not needed"))

coordinator = ChatCoordinator(
chatController = chatController,
messagingController = mockk(relaxed = true),
eventStreamingController = eventStreamingController,
metadataDataSource = mockk(relaxed = true),
messageDataSource = mockk(relaxed = true),
memberDataSource = mockk(relaxed = true),
contactDataSource = mockk(relaxed = true),
networkObserver = mockk<NetworkConnectivityListener>(relaxed = true),
notificationManager = mockk<NotificationManagerCompat>(relaxed = true),
userManager = userManager,
tokenCoordinator = tokenCoordinator,
featureFlags = mockk<FeatureFlagController>(relaxed = true),
)
}

private fun cashMessage(
senderId: List<Byte>?,
amount: Fiat = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD),
mint: Mint = this.mint,
) = ChatMessage(
messageId = 1L,
senderId = senderId,
content = listOf(MessageContent.Cash(
intentId = listOf(0),
amount = amount,
mint = mint,
)),
timestamp = Instant.fromEpochSeconds(1000),
unreadSeq = 0,
)

private fun textMessage(senderId: List<Byte>?) = ChatMessage(
messageId = 2L,
senderId = senderId,
content = listOf(MessageContent.Text("hello")),
timestamp = Instant.fromEpochSeconds(1000),
unreadSeq = 0,
)

private fun chatUpdate(vararg messages: ChatMessage) = ChatUpdate(
chatId = chatId,
newMessages = messages.toList(),
pointerUpdates = emptyList(),
typingNotifications = emptyList(),
metadataUpdates = emptyList(),
)

private suspend fun triggerCollection() {
coordinator.onUserLoggedIn(mockk(relaxed = true))
}

@Test
fun `incoming cash message triggers tokenCoordinator add`() = runTest {
triggerCollection()
val amount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD)
chatUpdatesChannel.send(chatUpdate(cashMessage(senderId = otherId, amount = amount)))
advanceUntilIdle()

coVerify(exactly = 1) { tokenCoordinator.add(mint, amount) }
}

@Test
fun `self-sent cash message does not trigger tokenCoordinator add`() = runTest {
triggerCollection()
chatUpdatesChannel.send(chatUpdate(cashMessage(senderId = selfId)))
advanceUntilIdle()

coVerify(exactly = 0) { tokenCoordinator.add(any<Mint>(), any()) }
}

@Test
fun `text message does not trigger tokenCoordinator add`() = runTest {
triggerCollection()
chatUpdatesChannel.send(chatUpdate(textMessage(senderId = otherId)))
advanceUntilIdle()

coVerify(exactly = 0) { tokenCoordinator.add(any<Mint>(), any()) }
}

@Test
fun `multiple cash messages in one update each trigger add`() = runTest {
triggerCollection()
val mintB = Mint("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBbbbbbbbbbbb")
val amount1 = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD)
val amount2 = Fiat(fiat = 10.0, currencyCode = CurrencyCode.USD)
val msg1 = cashMessage(senderId = otherId, amount = amount1, mint = mint)
val msg2 = cashMessage(senderId = otherId, amount = amount2, mint = mintB).copy(messageId = 3L)

chatUpdatesChannel.send(chatUpdate(msg1, msg2))
advanceUntilIdle()

coVerify(exactly = 1) { tokenCoordinator.add(mint, amount1) }
coVerify(exactly = 1) { tokenCoordinator.add(mintB, amount2) }
}

@Test
fun `mixed self and incoming messages only triggers add for incoming`() = runTest {
triggerCollection()
val incoming = cashMessage(senderId = otherId)
val outgoing = cashMessage(senderId = selfId).copy(messageId = 3L)

chatUpdatesChannel.send(chatUpdate(incoming, outgoing))
advanceUntilIdle()

coVerify(exactly = 1) { tokenCoordinator.add(any<Mint>(), any()) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.flipcash.app.tokens

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.solana.keys.Mint
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* Tests the conversion math used by [TokenCoordinator.add] and [TokenCoordinator.subtract]
* mint-based overloads.
*
* The key invariant: `rateFor(currency)` returns the rate FROM USD TO native
* (e.g. 1 USD = 1.37 CAD → fx=1.37, currency=CAD). [LocalFiat.fromNativeAmount]
* divides by this fx to recover the USD equivalent.
*
* Using `rateToUsd` (which inverts the fx) would produce the wrong USD amount.
*/
class TokenCoordinatorMintOverloadTest {

private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa")

// region fromNativeAmount rate convention

@Test
fun `fromNativeAmount with rateFor convention produces correct USD amount`() {
// 1 USD = 1.37 CAD (rateFor returns this)
val cadRate = Rate(fx = 1.37, currency = CurrencyCode.CAD)
val nativeAmount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD)

val localFiat = LocalFiat.fromNativeAmount(nativeAmount, cadRate, mint)

// 5 CAD / 1.37 = ~3.65 USD
val expectedUsd = 5.0 / 1.37
assertEquals(expectedUsd, localFiat.underlyingTokenAmount.decimalValue, 0.001)
assertEquals(CurrencyCode.USD, localFiat.underlyingTokenAmount.currencyCode)
assertEquals(5.0, localFiat.nativeAmount.decimalValue, 0.001)
assertEquals(CurrencyCode.CAD, localFiat.nativeAmount.currencyCode)
assertEquals(CurrencyCode.CAD, localFiat.rate.currency)
}

@Test
fun `fromNativeAmount with inverted rate (rateToUsd) produces wrong USD amount`() {
// rateToUsd returns: Rate(fx = 1/1.37 = 0.73, currency = USD)
val invertedRate = Rate(fx = 1.0 / 1.37, currency = CurrencyCode.USD)
val nativeAmount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD)

val localFiat = LocalFiat.fromNativeAmount(nativeAmount, invertedRate, mint)

// 5.0 / 0.73 = ~6.85 USD — WRONG (should be ~3.65)
val wrongUsd = 5.0 / (1.0 / 1.37)
assertEquals(wrongUsd, localFiat.underlyingTokenAmount.decimalValue, 0.001)
assertTrue(localFiat.underlyingTokenAmount.decimalValue > 5.0,
"Inverted rate produces inflated USD (${localFiat.underlyingTokenAmount.decimalValue}), proving rateToUsd is wrong for this use case")
}

// endregion

// region add pipeline end-to-end math

@Test
fun `LocalFiat from rateFor flows correctly through add conversion`() {
// Simulate what add(Token, LocalFiat) does internally:
// 1. Gets rateToUsd(currency) → Rate(fx = 1/1.37 = 0.73, currency = USD)
// 2. Calls nativeAmount.convertingTo(rate)
val cadRate = Rate(fx = 1.37, currency = CurrencyCode.CAD)
val nativeAmount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD)

val localFiat = LocalFiat.fromNativeAmount(nativeAmount, cadRate, mint)

// add() internally does: exchange.rateToUsd(fiat.rate.currency) → Rate(1/1.37, USD)
val rateToUsd = Rate(fx = 1.0 / cadRate.fx, currency = CurrencyCode.USD)
val usdAmount = localFiat.nativeAmount.convertingTo(rateToUsd)

// 5 CAD * (1/1.37) = ~3.65 USD
val expectedUsd = 5.0 / 1.37
assertEquals(expectedUsd, usdAmount.decimalValue, 0.001)
assertEquals(CurrencyCode.USD, usdAmount.currencyCode)
}

@Test
fun `USD amount uses oneToOne rate and passes through unchanged`() {
val nativeAmount = Fiat(fiat = 10.0, currencyCode = CurrencyCode.USD)

val localFiat = LocalFiat.fromNativeAmount(nativeAmount, Rate.oneToOne, Mint.usdf)

assertEquals(10.0, localFiat.underlyingTokenAmount.decimalValue, 0.001)
assertEquals(10.0, localFiat.nativeAmount.decimalValue, 0.001)

// add() pipeline: rateToUsd(USD) = Rate(1.0, USD)
val usdAmount = localFiat.nativeAmount.convertingTo(Rate.oneToOne)
assertEquals(10.0, usdAmount.decimalValue, 0.001)
}

// endregion
}
Loading