diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index 517c1b879..7208f63cb 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) implementation(libs.bundles.kotlinx.serialization) @@ -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) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index a7de36d1f..37e277f25 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -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 @@ -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 { @@ -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) { diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt new file mode 100644 index 000000000..6d400abb4 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -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(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val chatId = ChatId("aabbccdd") + private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") + + private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) + + private lateinit var tokenCoordinator: TokenCoordinator + private lateinit var coordinator: ChatCoordinator + + @Before + fun setUp() { + tokenCoordinator = mockk(relaxed = true) + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + val eventStreamingController = mockk(relaxed = true) + every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow() + every { eventStreamingController.isConnected } returns true + every { eventStreamingController.isStreamActive } returns true + + val chatController = mockk(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(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = userManager, + tokenCoordinator = tokenCoordinator, + featureFlags = mockk(relaxed = true), + ) + } + + private fun cashMessage( + senderId: List?, + 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?) = 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(), 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(), 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(), any()) } + } +} diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenCoordinatorMintOverloadTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenCoordinatorMintOverloadTest.kt new file mode 100644 index 000000000..66b55e526 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenCoordinatorMintOverloadTest.kt @@ -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 +}