Skip to content

Commit becd4ab

Browse files
committed
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 <git@bmcreations.dev>
1 parent 779eda2 commit becd4ab

4 files changed

Lines changed: 286 additions & 0 deletions

File tree

apps/flipcash/shared/chat/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ android {
99
dependencies {
1010
testImplementation(kotlin("test"))
1111
testImplementation(libs.bundles.unit.testing)
12+
testImplementation(libs.robolectric)
1213

1314
implementation(libs.bundles.kotlinx.serialization)
1415

@@ -18,6 +19,7 @@ dependencies {
1819
implementation(project(":apps:flipcash:shared:persistence:db"))
1920
implementation(project(":apps:flipcash:shared:contacts"))
2021
implementation(project(":apps:flipcash:shared:featureflags"))
22+
implementation(project(":apps:flipcash:shared:tokens"))
2123
implementation(project(":services:flipcash"))
2224
implementation(project(":libs:network:connectivity:public"))
2325
implementation(libs.androidx.lifecycle.process)

apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.flipcash.services.models.chat.MetadataUpdate
3434
import com.flipcash.services.models.chat.PointerType
3535
import com.flipcash.services.models.chat.TypingNotification
3636
import com.flipcash.services.models.chat.TypingState
37+
import com.flipcash.app.tokens.TokenCoordinator
3738
import com.flipcash.services.user.UserManager
3839
import com.getcode.opencode.model.accounts.AccountCluster
3940
import com.getcode.opencode.providers.SessionListener
@@ -79,6 +80,7 @@ class ChatCoordinator @Inject constructor(
7980
private val networkObserver: NetworkConnectivityListener,
8081
private val notificationManager: NotificationManagerCompat,
8182
private val userManager: UserManager,
83+
private val tokenCoordinator: TokenCoordinator,
8284
private val featureFlags: FeatureFlagController,
8385
) : SessionListener, DefaultLifecycleObserver {
8486

@@ -518,6 +520,18 @@ class ChatCoordinator @Inject constructor(
518520
}
519521
}
520522

523+
// --- Eagerly update token balance for incoming cash ---
524+
525+
val selfId = userManager.accountId
526+
for (msg in update.newMessages) {
527+
if (msg.senderId == selfId) continue
528+
for (content in msg.content) {
529+
if (content is MessageContent.Cash) {
530+
tokenCoordinator.add(content.mint, content.amount)
531+
}
532+
}
533+
}
534+
521535
// --- Check if unknown chat requires a full feed sync ---
522536

523537
if (lastMsg != null) {
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.flipcash.shared.chat
2+
3+
import androidx.core.app.NotificationManagerCompat
4+
import com.flipcash.app.featureflags.FeatureFlagController
5+
import com.flipcash.app.persistence.sources.ChatMemberDataSource
6+
import com.flipcash.app.persistence.sources.ChatMessageDataSource
7+
import com.flipcash.app.persistence.sources.ChatMetadataDataSource
8+
import com.flipcash.app.persistence.sources.ContactDataSource
9+
import com.flipcash.app.tokens.TokenCoordinator
10+
import com.flipcash.services.controllers.ChatController
11+
import com.flipcash.services.controllers.ChatMessagingController
12+
import com.flipcash.services.controllers.EventStreamingController
13+
import com.flipcash.services.models.chat.ChatId
14+
import com.flipcash.services.models.chat.ChatMessage
15+
import com.flipcash.services.models.chat.ChatUpdate
16+
import com.flipcash.services.models.chat.MessageContent
17+
import com.getcode.opencode.model.financial.CurrencyCode
18+
import com.getcode.opencode.model.financial.Fiat
19+
import com.getcode.solana.keys.Mint
20+
import com.getcode.utils.network.NetworkConnectivityListener
21+
import com.flipcash.services.user.UserManager
22+
import io.mockk.coEvery
23+
import io.mockk.coVerify
24+
import io.mockk.every
25+
import io.mockk.mockk
26+
import kotlinx.coroutines.ExperimentalCoroutinesApi
27+
import kotlinx.coroutines.channels.Channel
28+
import kotlinx.coroutines.flow.receiveAsFlow
29+
import kotlinx.coroutines.test.advanceUntilIdle
30+
import kotlinx.coroutines.test.runTest
31+
import org.junit.Before
32+
import org.junit.Test
33+
import org.junit.runner.RunWith
34+
import org.robolectric.RobolectricTestRunner
35+
import kotlin.time.Instant
36+
37+
@OptIn(ExperimentalCoroutinesApi::class)
38+
@RunWith(RobolectricTestRunner::class)
39+
class ChatCoordinatorEagerBalanceTest {
40+
41+
private val selfId = listOf<Byte>(1, 2, 3)
42+
private val otherId = listOf<Byte>(4, 5, 6)
43+
private val chatId = ChatId("aabbccdd")
44+
private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa")
45+
46+
private val chatUpdatesChannel = Channel<ChatUpdate>(capacity = Channel.UNLIMITED)
47+
48+
private lateinit var tokenCoordinator: TokenCoordinator
49+
private lateinit var coordinator: ChatCoordinator
50+
51+
@Before
52+
fun setUp() {
53+
tokenCoordinator = mockk(relaxed = true)
54+
val userManager = mockk<UserManager>(relaxed = true)
55+
every { userManager.accountId } returns selfId
56+
val eventStreamingController = mockk<EventStreamingController>(relaxed = true)
57+
every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow()
58+
every { eventStreamingController.isConnected } returns true
59+
every { eventStreamingController.isStreamActive } returns true
60+
61+
val chatController = mockk<ChatController>(relaxed = true)
62+
coEvery { chatController.getDmChatFeed() } returns Result.failure(RuntimeException("not needed"))
63+
64+
coordinator = ChatCoordinator(
65+
chatController = chatController,
66+
messagingController = mockk(relaxed = true),
67+
eventStreamingController = eventStreamingController,
68+
metadataDataSource = mockk(relaxed = true),
69+
messageDataSource = mockk(relaxed = true),
70+
memberDataSource = mockk(relaxed = true),
71+
contactDataSource = mockk(relaxed = true),
72+
networkObserver = mockk<NetworkConnectivityListener>(relaxed = true),
73+
notificationManager = mockk<NotificationManagerCompat>(relaxed = true),
74+
userManager = userManager,
75+
tokenCoordinator = tokenCoordinator,
76+
featureFlags = mockk<FeatureFlagController>(relaxed = true),
77+
)
78+
}
79+
80+
private fun cashMessage(
81+
senderId: List<Byte>?,
82+
amount: Fiat = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD),
83+
mint: Mint = this.mint,
84+
) = ChatMessage(
85+
messageId = 1L,
86+
senderId = senderId,
87+
content = listOf(MessageContent.Cash(
88+
intentId = listOf(0),
89+
amount = amount,
90+
mint = mint,
91+
)),
92+
timestamp = Instant.fromEpochSeconds(1000),
93+
unreadSeq = 0,
94+
)
95+
96+
private fun textMessage(senderId: List<Byte>?) = ChatMessage(
97+
messageId = 2L,
98+
senderId = senderId,
99+
content = listOf(MessageContent.Text("hello")),
100+
timestamp = Instant.fromEpochSeconds(1000),
101+
unreadSeq = 0,
102+
)
103+
104+
private fun chatUpdate(vararg messages: ChatMessage) = ChatUpdate(
105+
chatId = chatId,
106+
newMessages = messages.toList(),
107+
pointerUpdates = emptyList(),
108+
typingNotifications = emptyList(),
109+
metadataUpdates = emptyList(),
110+
)
111+
112+
private suspend fun triggerCollection() {
113+
coordinator.onUserLoggedIn(mockk(relaxed = true))
114+
}
115+
116+
@Test
117+
fun `incoming cash message triggers tokenCoordinator add`() = runTest {
118+
triggerCollection()
119+
val amount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD)
120+
chatUpdatesChannel.send(chatUpdate(cashMessage(senderId = otherId, amount = amount)))
121+
advanceUntilIdle()
122+
123+
coVerify(exactly = 1) { tokenCoordinator.add(mint, amount) }
124+
}
125+
126+
@Test
127+
fun `self-sent cash message does not trigger tokenCoordinator add`() = runTest {
128+
triggerCollection()
129+
chatUpdatesChannel.send(chatUpdate(cashMessage(senderId = selfId)))
130+
advanceUntilIdle()
131+
132+
coVerify(exactly = 0) { tokenCoordinator.add(any<Mint>(), any()) }
133+
}
134+
135+
@Test
136+
fun `text message does not trigger tokenCoordinator add`() = runTest {
137+
triggerCollection()
138+
chatUpdatesChannel.send(chatUpdate(textMessage(senderId = otherId)))
139+
advanceUntilIdle()
140+
141+
coVerify(exactly = 0) { tokenCoordinator.add(any<Mint>(), any()) }
142+
}
143+
144+
@Test
145+
fun `multiple cash messages in one update each trigger add`() = runTest {
146+
triggerCollection()
147+
val mintB = Mint("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBbbbbbbbbbbb")
148+
val amount1 = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD)
149+
val amount2 = Fiat(fiat = 10.0, currencyCode = CurrencyCode.USD)
150+
val msg1 = cashMessage(senderId = otherId, amount = amount1, mint = mint)
151+
val msg2 = cashMessage(senderId = otherId, amount = amount2, mint = mintB).copy(messageId = 3L)
152+
153+
chatUpdatesChannel.send(chatUpdate(msg1, msg2))
154+
advanceUntilIdle()
155+
156+
coVerify(exactly = 1) { tokenCoordinator.add(mint, amount1) }
157+
coVerify(exactly = 1) { tokenCoordinator.add(mintB, amount2) }
158+
}
159+
160+
@Test
161+
fun `mixed self and incoming messages only triggers add for incoming`() = runTest {
162+
triggerCollection()
163+
val incoming = cashMessage(senderId = otherId)
164+
val outgoing = cashMessage(senderId = selfId).copy(messageId = 3L)
165+
166+
chatUpdatesChannel.send(chatUpdate(incoming, outgoing))
167+
advanceUntilIdle()
168+
169+
coVerify(exactly = 1) { tokenCoordinator.add(any<Mint>(), any()) }
170+
}
171+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.flipcash.app.tokens
2+
3+
import com.getcode.opencode.model.financial.CurrencyCode
4+
import com.getcode.opencode.model.financial.Fiat
5+
import com.getcode.opencode.model.financial.LocalFiat
6+
import com.getcode.opencode.model.financial.Rate
7+
import com.getcode.solana.keys.Mint
8+
import kotlin.test.Test
9+
import kotlin.test.assertEquals
10+
import kotlin.test.assertTrue
11+
12+
/**
13+
* Tests the conversion math used by [TokenCoordinator.add] and [TokenCoordinator.subtract]
14+
* mint-based overloads.
15+
*
16+
* The key invariant: `rateFor(currency)` returns the rate FROM USD TO native
17+
* (e.g. 1 USD = 1.37 CAD → fx=1.37, currency=CAD). [LocalFiat.fromNativeAmount]
18+
* divides by this fx to recover the USD equivalent.
19+
*
20+
* Using `rateToUsd` (which inverts the fx) would produce the wrong USD amount.
21+
*/
22+
class TokenCoordinatorMintOverloadTest {
23+
24+
private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa")
25+
26+
// region fromNativeAmount rate convention
27+
28+
@Test
29+
fun `fromNativeAmount with rateFor convention produces correct USD amount`() {
30+
// 1 USD = 1.37 CAD (rateFor returns this)
31+
val cadRate = Rate(fx = 1.37, currency = CurrencyCode.CAD)
32+
val nativeAmount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD)
33+
34+
val localFiat = LocalFiat.fromNativeAmount(nativeAmount, cadRate, mint)
35+
36+
// 5 CAD / 1.37 = ~3.65 USD
37+
val expectedUsd = 5.0 / 1.37
38+
assertEquals(expectedUsd, localFiat.underlyingTokenAmount.decimalValue, 0.001)
39+
assertEquals(CurrencyCode.USD, localFiat.underlyingTokenAmount.currencyCode)
40+
assertEquals(5.0, localFiat.nativeAmount.decimalValue, 0.001)
41+
assertEquals(CurrencyCode.CAD, localFiat.nativeAmount.currencyCode)
42+
assertEquals(CurrencyCode.CAD, localFiat.rate.currency)
43+
}
44+
45+
@Test
46+
fun `fromNativeAmount with inverted rate (rateToUsd) produces wrong USD amount`() {
47+
// rateToUsd returns: Rate(fx = 1/1.37 = 0.73, currency = USD)
48+
val invertedRate = Rate(fx = 1.0 / 1.37, currency = CurrencyCode.USD)
49+
val nativeAmount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD)
50+
51+
val localFiat = LocalFiat.fromNativeAmount(nativeAmount, invertedRate, mint)
52+
53+
// 5.0 / 0.73 = ~6.85 USD — WRONG (should be ~3.65)
54+
val wrongUsd = 5.0 / (1.0 / 1.37)
55+
assertEquals(wrongUsd, localFiat.underlyingTokenAmount.decimalValue, 0.001)
56+
assertTrue(localFiat.underlyingTokenAmount.decimalValue > 5.0,
57+
"Inverted rate produces inflated USD (${localFiat.underlyingTokenAmount.decimalValue}), proving rateToUsd is wrong for this use case")
58+
}
59+
60+
// endregion
61+
62+
// region add pipeline end-to-end math
63+
64+
@Test
65+
fun `LocalFiat from rateFor flows correctly through add conversion`() {
66+
// Simulate what add(Token, LocalFiat) does internally:
67+
// 1. Gets rateToUsd(currency) → Rate(fx = 1/1.37 = 0.73, currency = USD)
68+
// 2. Calls nativeAmount.convertingTo(rate)
69+
val cadRate = Rate(fx = 1.37, currency = CurrencyCode.CAD)
70+
val nativeAmount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.CAD)
71+
72+
val localFiat = LocalFiat.fromNativeAmount(nativeAmount, cadRate, mint)
73+
74+
// add() internally does: exchange.rateToUsd(fiat.rate.currency) → Rate(1/1.37, USD)
75+
val rateToUsd = Rate(fx = 1.0 / cadRate.fx, currency = CurrencyCode.USD)
76+
val usdAmount = localFiat.nativeAmount.convertingTo(rateToUsd)
77+
78+
// 5 CAD * (1/1.37) = ~3.65 USD
79+
val expectedUsd = 5.0 / 1.37
80+
assertEquals(expectedUsd, usdAmount.decimalValue, 0.001)
81+
assertEquals(CurrencyCode.USD, usdAmount.currencyCode)
82+
}
83+
84+
@Test
85+
fun `USD amount uses oneToOne rate and passes through unchanged`() {
86+
val nativeAmount = Fiat(fiat = 10.0, currencyCode = CurrencyCode.USD)
87+
88+
val localFiat = LocalFiat.fromNativeAmount(nativeAmount, Rate.oneToOne, Mint.usdf)
89+
90+
assertEquals(10.0, localFiat.underlyingTokenAmount.decimalValue, 0.001)
91+
assertEquals(10.0, localFiat.nativeAmount.decimalValue, 0.001)
92+
93+
// add() pipeline: rateToUsd(USD) = Rate(1.0, USD)
94+
val usdAmount = localFiat.nativeAmount.convertingTo(Rate.oneToOne)
95+
assertEquals(10.0, usdAmount.decimalValue, 0.001)
96+
}
97+
98+
// endregion
99+
}

0 commit comments

Comments
 (0)