From becd4ab1b6b559b82369a4cbb8ef1e686e561d46 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 20 Jun 2026 10:19:11 -0400 Subject: [PATCH] feat(chat): eagerly update token balance on incoming cash messages When a cash message arrives via the event stream, ChatCoordinator now calls tokenCoordinator.add(mint, amount) so the recipient balance reflects the funds immediately without waiting for the next sync cycle. Adds unit tests for both the ChatCoordinator eager dispatch logic and the TokenCoordinator mint-based add/subtract conversion math. Signed-off-by: Brandon McAnsh --- apps/flipcash/shared/chat/build.gradle.kts | 2 + .../flipcash/shared/chat/ChatCoordinator.kt | 14 ++ .../chat/ChatCoordinatorEagerBalanceTest.kt | 171 ++++++++++++++++++ .../TokenCoordinatorMintOverloadTest.kt | 99 ++++++++++ 4 files changed, 286 insertions(+) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenCoordinatorMintOverloadTest.kt 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 +}