Skip to content

Commit 4acf338

Browse files
committed
feat(tokens): extract token selection logic with exchange rate threshold
Refactor ensureValidTokenSelection into a pure function (resolveTokenSelection) that uses a minimum native-currency threshold (0.01) based on the exchange rate to decide whether to keep or switch the selected token. Also triggers re-selection when balances are modified. Includes comprehensive unit tests. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent d577b26 commit 4acf338

4 files changed

Lines changed: 323 additions & 11 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ android {
77
}
88

99
dependencies {
10+
testImplementation(kotlin("test"))
11+
1012
implementation(libs.androidx.datastore)
1113

1214
implementation(libs.androidx.lifecycle.process)

apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,7 @@ class TokenCoordinator @Inject constructor(
431431
val newBalance = operation(currentBalance, amount)
432432
trace(tag = TAG, message = "Modified ${token.symbol} balance: ${currentBalance.formatted()} -> ${newBalance.formatted()}", type = TraceType.Process)
433433
_state.update { it.copy(balances = it.balances + (token.address to newBalance)) }
434+
ensureValidTokenSelection()
434435

435436
scope.launch(Dispatchers.IO) {
436437
updateTokenAccount(token.address)
@@ -495,22 +496,18 @@ class TokenCoordinator @Inject constructor(
495496
}
496497

497498
private suspend fun ensureValidTokenSelection() {
498-
val state = _state.value
499-
if (state.balances.isEmpty()) return
500-
501499
val currentSelection = selectedToken.data.firstOrNull()
502500
?.get(mintPreferenceKey)
503501
?.let { Mint(it) }
504-
?.takeIf { state.balances.containsKey(it) }
505-
506-
if (currentSelection != null) return
507502

508-
val highestBalance = state.balances
509-
.filterKeys { it != Mint.usdf }
510-
.maxByOrNull { it.value }
503+
val resolved = resolveTokenSelection(
504+
balances = _state.value.balances,
505+
currentSelection = currentSelection,
506+
rate = exchange.entryRate,
507+
)
511508

512-
if (highestBalance != null) {
513-
selectToken(highestBalance.key)
509+
if (resolved != null && resolved != currentSelection) {
510+
selectToken(resolved)
514511
}
515512
}
516513

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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.Rate
6+
import com.getcode.opencode.model.financial.toFiat
7+
import com.getcode.solana.keys.Mint
8+
9+
/**
10+
* Pure logic for resolving which token should be selected based on balances and exchange rate.
11+
*
12+
* Returns the [Mint] that should be selected, or `null` if no valid token exists.
13+
*/
14+
internal fun resolveTokenSelection(
15+
balances: Map<Mint, Fiat>,
16+
currentSelection: Mint?,
17+
rate: Rate,
18+
): Mint? {
19+
if (balances.isEmpty()) return null
20+
21+
val baseline = 0.01.toFiat(rate.currency)
22+
23+
fun Fiat.meetsThreshold(): Boolean {
24+
val native = convertingTo(rate)
25+
return native.valueNonZero() && native.valueGreaterThanOrEqualTo(baseline)
26+
}
27+
28+
// Keep current selection if it still meets the threshold
29+
if (currentSelection != null) {
30+
val balance = balances[currentSelection]
31+
if (balance != null && balance.meetsThreshold()) {
32+
return currentSelection
33+
}
34+
}
35+
36+
// Fall back to highest non-USDF balance that meets the threshold
37+
return balances
38+
.filterKeys { it != Mint.usdf }
39+
.filter { it.value.meetsThreshold() }
40+
.maxByOrNull { it.value }
41+
?.key
42+
}
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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.Rate
6+
import com.getcode.solana.keys.Mint
7+
import kotlin.test.Test
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertNull
10+
11+
class TokenSelectionResolverTest {
12+
13+
private val mintA = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa")
14+
private val mintB = Mint("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBbbbbbbbbbbb")
15+
private val mintC = Mint("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCccccccccccc")
16+
17+
private val usdRate = Rate(fx = 1.0, currency = CurrencyCode.USD)
18+
19+
// region Empty balances
20+
21+
@Test
22+
fun `returns null when balances are empty`() {
23+
val result = resolveTokenSelection(
24+
balances = emptyMap(),
25+
currentSelection = mintA,
26+
rate = usdRate,
27+
)
28+
assertNull(result)
29+
}
30+
31+
// endregion
32+
33+
// region Current selection retained
34+
35+
@Test
36+
fun `keeps current selection when balance is above threshold`() {
37+
val result = resolveTokenSelection(
38+
balances = mapOf(
39+
mintA to Fiat(5.00, CurrencyCode.USD),
40+
mintB to Fiat(10.00, CurrencyCode.USD),
41+
),
42+
currentSelection = mintA,
43+
rate = usdRate,
44+
)
45+
assertEquals(mintA, result)
46+
}
47+
48+
@Test
49+
fun `keeps current selection at exactly the threshold`() {
50+
val result = resolveTokenSelection(
51+
balances = mapOf(
52+
mintA to Fiat(0.01, CurrencyCode.USD),
53+
),
54+
currentSelection = mintA,
55+
rate = usdRate,
56+
)
57+
assertEquals(mintA, result)
58+
}
59+
60+
// endregion
61+
62+
// region Zero balance triggers re-selection
63+
64+
@Test
65+
fun `selects next highest when current balance is zero`() {
66+
val result = resolveTokenSelection(
67+
balances = mapOf(
68+
mintA to Fiat(0.0, CurrencyCode.USD),
69+
mintB to Fiat(10.00, CurrencyCode.USD),
70+
),
71+
currentSelection = mintA,
72+
rate = usdRate,
73+
)
74+
assertEquals(mintB, result)
75+
}
76+
77+
@Test
78+
fun `selects next highest when current balance drops below threshold`() {
79+
// 0.004 USD rounds to 0.00 with HALF_UP — below 0.01 threshold
80+
val result = resolveTokenSelection(
81+
balances = mapOf(
82+
mintA to Fiat(0.004, CurrencyCode.USD),
83+
mintB to Fiat(3.00, CurrencyCode.USD),
84+
),
85+
currentSelection = mintA,
86+
rate = usdRate,
87+
)
88+
assertEquals(mintB, result)
89+
}
90+
91+
@Test
92+
fun `returns null when all balances are zero`() {
93+
val result = resolveTokenSelection(
94+
balances = mapOf(
95+
mintA to Fiat(0.0, CurrencyCode.USD),
96+
mintB to Fiat(0.0, CurrencyCode.USD),
97+
),
98+
currentSelection = mintA,
99+
rate = usdRate,
100+
)
101+
assertNull(result)
102+
}
103+
104+
@Test
105+
fun `returns null when all balances are below threshold`() {
106+
// Both round to 0.00 with HALF_UP
107+
val result = resolveTokenSelection(
108+
balances = mapOf(
109+
mintA to Fiat(0.001, CurrencyCode.USD),
110+
mintB to Fiat(0.004, CurrencyCode.USD),
111+
),
112+
currentSelection = mintA,
113+
rate = usdRate,
114+
)
115+
assertNull(result)
116+
}
117+
118+
// endregion
119+
120+
// region Fallback picks highest balance
121+
122+
@Test
123+
fun `selects highest balance token when no current selection`() {
124+
val result = resolveTokenSelection(
125+
balances = mapOf(
126+
mintA to Fiat(5.00, CurrencyCode.USD),
127+
mintB to Fiat(20.00, CurrencyCode.USD),
128+
mintC to Fiat(10.00, CurrencyCode.USD),
129+
),
130+
currentSelection = null,
131+
rate = usdRate,
132+
)
133+
assertEquals(mintB, result)
134+
}
135+
136+
@Test
137+
fun `selects highest balance when current selection mint is missing from balances`() {
138+
val result = resolveTokenSelection(
139+
balances = mapOf(
140+
mintB to Fiat(7.00, CurrencyCode.USD),
141+
mintC to Fiat(3.00, CurrencyCode.USD),
142+
),
143+
currentSelection = mintA,
144+
rate = usdRate,
145+
)
146+
assertEquals(mintB, result)
147+
}
148+
149+
// endregion
150+
151+
// region USDF exclusion
152+
153+
@Test
154+
fun `never selects USDF as fallback`() {
155+
val result = resolveTokenSelection(
156+
balances = mapOf(
157+
mintA to Fiat(0.0, CurrencyCode.USD),
158+
Mint.usdf to Fiat(100.00, CurrencyCode.USD),
159+
),
160+
currentSelection = mintA,
161+
rate = usdRate,
162+
)
163+
assertNull(result)
164+
}
165+
166+
@Test
167+
fun `skips USDF and selects next highest`() {
168+
val result = resolveTokenSelection(
169+
balances = mapOf(
170+
mintA to Fiat(0.0, CurrencyCode.USD),
171+
Mint.usdf to Fiat(100.00, CurrencyCode.USD),
172+
mintB to Fiat(5.00, CurrencyCode.USD),
173+
),
174+
currentSelection = mintA,
175+
rate = usdRate,
176+
)
177+
assertEquals(mintB, result)
178+
}
179+
180+
@Test
181+
fun `retains USDF as current selection if it meets threshold`() {
182+
val result = resolveTokenSelection(
183+
balances = mapOf(
184+
Mint.usdf to Fiat(50.00, CurrencyCode.USD),
185+
mintA to Fiat(10.00, CurrencyCode.USD),
186+
),
187+
currentSelection = Mint.usdf,
188+
rate = usdRate,
189+
)
190+
assertEquals(Mint.usdf, result)
191+
}
192+
193+
// endregion
194+
195+
// region Exchange rate sensitivity
196+
197+
@Test
198+
fun `balance below threshold in USD but valid with high fx rate`() {
199+
// 0.004 USD rounds to $0.00 at 1:1, but 0.004 × 200 = 0.80 ARS — above 0.01
200+
val arsRate = Rate(fx = 200.0, currency = CurrencyCode.ARS)
201+
val result = resolveTokenSelection(
202+
balances = mapOf(
203+
mintA to Fiat(0.004, CurrencyCode.USD),
204+
),
205+
currentSelection = mintA,
206+
rate = arsRate,
207+
)
208+
assertEquals(mintA, result)
209+
}
210+
211+
@Test
212+
fun `balance above zero in USD but below threshold in native currency`() {
213+
// 0.004 USD × 1.0 EUR/USD = 0.004 EUR — rounds to 0.00, below 0.01
214+
val eurRate = Rate(fx = 1.0, currency = CurrencyCode.EUR)
215+
val result = resolveTokenSelection(
216+
balances = mapOf(
217+
mintA to Fiat(0.004, CurrencyCode.USD),
218+
mintB to Fiat(1.00, CurrencyCode.USD),
219+
),
220+
currentSelection = mintA,
221+
rate = eurRate,
222+
)
223+
assertEquals(mintB, result)
224+
}
225+
226+
@Test
227+
fun `tiny USD balance rounds to zero even with moderate fx rate`() {
228+
// 0.00001 USD × 150 JPY/USD = 0.0015 JPY — below 0.01 JPY threshold
229+
val jpyRate = Rate(fx = 150.0, currency = CurrencyCode.JPY)
230+
val result = resolveTokenSelection(
231+
balances = mapOf(
232+
mintA to Fiat(0.00001, CurrencyCode.USD),
233+
),
234+
currentSelection = mintA,
235+
rate = jpyRate,
236+
)
237+
assertNull(result)
238+
}
239+
240+
@Test
241+
fun `selection changes when rate makes current balance fall below threshold`() {
242+
// At 1:1 rate, 0.02 USD = 0.02 in native → above threshold
243+
// At 0.2 rate, 0.02 USD = 0.004 in native → rounds to 0.00, below threshold
244+
val lowRate = Rate(fx = 0.2, currency = CurrencyCode.GBP)
245+
val result = resolveTokenSelection(
246+
balances = mapOf(
247+
mintA to Fiat(0.02, CurrencyCode.USD),
248+
mintB to Fiat(1.00, CurrencyCode.USD),
249+
),
250+
currentSelection = mintA,
251+
rate = lowRate,
252+
)
253+
assertEquals(mintB, result)
254+
}
255+
256+
@Test
257+
fun `high fx rate rescues very small balances`() {
258+
// 0.0001 USD × 1200 ARS/USD = 0.12 ARS — above 0.01 ARS threshold
259+
val arsRate = Rate(fx = 1200.0, currency = CurrencyCode.ARS)
260+
val result = resolveTokenSelection(
261+
balances = mapOf(
262+
mintA to Fiat(0.0001, CurrencyCode.USD),
263+
),
264+
currentSelection = mintA,
265+
rate = arsRate,
266+
)
267+
assertEquals(mintA, result)
268+
}
269+
270+
// endregion
271+
}

0 commit comments

Comments
 (0)