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
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ internal fun CurrencyInfoContentV2(
isHeld = isHeld,
tokenMint = token.address,
shortfall = shortfall,
convertEnabled = state.canConvert,
dispatch = dispatch,
)
}
Expand Down Expand Up @@ -349,6 +350,7 @@ private fun CurrencyActionTiles(
isHeld: Boolean,
tokenMint: Mint,
shortfall: Fiat?,
convertEnabled: Boolean,
dispatch: (TokenInfoViewModel.Event) -> Unit,
modifier: Modifier = Modifier,
) {
Expand Down Expand Up @@ -387,6 +389,7 @@ private fun CurrencyActionTiles(
)
},
onClick = { dispatch(TokenInfoViewModel.Event.OnConvert) },
enabled = convertEnabled,
)
}

Expand Down Expand Up @@ -462,19 +465,23 @@ private fun CurrencyActionTiles(
}
}

private const val DisabledActionTileAlpha = 0.38f

@Composable
private fun ActionTile(
label: String,
icon: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Column(
modifier = modifier
.alpha(if (enabled) 1f else DisabledActionTileAlpha)
.height(CodeTheme.dimens.staticGrid.x18)
.clip(CodeTheme.shapes.extraSmall)
.background(Color.White.copy(alpha = 0.1f))
.clickable(onClick = onClick),
.clickable(enabled = enabled, onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,12 @@ class SwapViewModel @Inject constructor(
) { balances, rate ->
if (destination == source) {
val fallback = if (source == Mint.usdf) {
balances.filter { it.token.address != source }
// Same candidates as the destination picker: held currencies with a
// displayable balance. The coordinator keeps zero-balance rows for
// accounts it no longer sees, and the server rejects a buy into one.
balances.filter {
it.token.address != source && it.balance.hasDisplayableValue
}
.maxByOrNull { it.balance }?.token?.address
} else {
Mint.usdf
Expand Down Expand Up @@ -1186,11 +1191,16 @@ class SwapViewModel @Inject constructor(
.filterIsInstance<Event.ProceedWithConversion>()
.onEach { dispatchEvent(Event.UpdateSellState(loading = true)) }
.mapNotNull { event ->
val owner = userManager.accountCluster ?: return@mapNotNull null
stateFlow.value.purpose as? SwapPurpose.Convert ?: return@mapNotNull null
val source = stateFlow.value.tokenWithBalance?.token ?: return@mapNotNull null
val owner = userManager.accountCluster
val source = stateFlow.value.tokenWithBalance?.token
val destination = stateFlow.value.destinationTokenWithBalance?.token
?: return@mapNotNull null
if (owner == null || stateFlow.value.purpose !is SwapPurpose.Convert ||
source == null || destination == null
) {
// Nothing to submit; drop the spinner the confirm tap turned on.
dispatchEvent(Event.UpdateSellState(loading = false))
return@mapNotNull null
}
Triple(owner, source to destination, event.amount)
}
.onEach { (owner, tokens, amount) ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ class TokenInfoViewModel @Inject constructor(

val isCashReserve: Boolean
get() = token.dataOrNull?.address == Mint.usdf

/**
* Whether Convert has somewhere to go. Out of any other currency, Dollars is always a valid
* destination. Out of Dollars, the user needs another currency with a displayable balance:
* the same `hasDisplayableValue` filter the swap screen uses to pick a default destination.
* [fundableBalanceMints] is empty until balances load, so Convert from Dollars starts off.
*/
val canConvert: Boolean
get() = mint != Mint.usdf || hasFundableBalance
}

sealed interface Event {
Expand Down Expand Up @@ -341,8 +350,9 @@ class TokenInfoViewModel @Inject constructor(
.filterIsInstance<Event.OnConvert>()
.onEach {
val mint = stateFlow.value.mint ?: return@onEach
// No gate, matching iOS: the tile only shows for a held currency, and the
// destination needs no balance of its own.
// The tile is disabled when this is false; checked again here so nothing else can
// open a Convert out of Dollars with no destination.
if (!stateFlow.value.canConvert) return@onEach

// Dollars is the default landing spot; when Dollars *is* the source the swap
// view model substitutes the user's largest other holding.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,4 +518,67 @@ class SwapViewModelErrorTest {
// Rate.ignore would convert this to ~0 and clear every ceiling. Fail closed instead.
assertTrue(vm.checkFundingAmount())
}

private fun held(mint: Mint, usd: Double): TokenWithBalance {
val token = mockk<Token>(relaxed = true) { every { address } returns mint }
return mockk(relaxed = true) {
every { this@mockk.token } returns token
every { this@mockk.balance } returns Fiat(usd)
}
}

private fun convertFromDollars(balances: List<TokenWithBalance>): SwapViewModel {
every { tokenCoordinator.tokenBalances } returns MutableStateFlow(balances)
// The destination resolver waits on a rate; a relaxed mock's flow never emits.
every { exchange.observePreferredRate() } returns
MutableStateFlow(Rate(fx = 1.0, currency = CurrencyCode.USD))
val vm = createViewModel()
vm.dispatchEvent(
SwapViewModel.Event.OnPurposeChanged(
SwapPurpose.Convert(mint = Mint.usdf, destinationMint = Mint.usdf)
)
)
return vm
}

@Test
fun `converting from Dollars defaults to the largest other holding`() =
runTest(mainCoroutineRule.dispatcher) {
dispatchers = TestDispatchers(testScheduler)
val small = Mint(ByteArray(32) { 2 }.toList())
val large = Mint(ByteArray(32) { 3 }.toList())
val vm = convertFromDollars(
listOf(held(Mint.usdf, 5.0), held(small, 1.0), held(large, 3.0))
)
advanceUntilIdle()

val purpose = assertIs<SwapPurpose.Convert>(vm.stateFlow.value.purpose)
assertEquals(large, purpose.destinationMint)
}

@Test
fun `converting from Dollars never defaults to an empty holding`() =
runTest(mainCoroutineRule.dispatcher) {
dispatchers = TestDispatchers(testScheduler)
// A zero-balance row the coordinator kept after the account left the server's list.
val stale = Mint(ByteArray(32) { 4 }.toList())
val vm = convertFromDollars(listOf(held(Mint.usdf, 5.0), held(stale, 0.0)))
advanceUntilIdle()

val purpose = assertIs<SwapPurpose.Convert>(vm.stateFlow.value.purpose)
assertEquals(Mint.usdf, purpose.destinationMint)
}

@Test
fun `confirming a conversion without a destination clears the loading state`() =
runTest(mainCoroutineRule.dispatcher) {
dispatchers = TestDispatchers(testScheduler)
val vm = convertFromDollars(listOf(held(Mint.usdf, 5.0)))
advanceUntilIdle()

vm.dispatchEvent(SwapViewModel.Event.OnConvertConfirmed)
advanceUntilIdle()

assertFalse(vm.stateFlow.value.sellProgress.loading)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.flipcash.app.tokens.ui

import com.flipcash.app.core.MainCoroutineRule
import com.flipcash.app.core.dispatchers.TestDispatchers
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.shareable.ShareSheetController
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.tokens.SwapPurpose
import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator
import com.getcode.manager.BottomBarManager
import com.getcode.opencode.controllers.AccountController
import com.getcode.opencode.exchange.Exchange
import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.DataSource
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.Rate
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.financial.TokenResult
import com.getcode.opencode.model.financial.TokenWithBalance
import com.getcode.solana.keys.Mint
import com.getcode.util.resources.FakeResourceHelper
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue

/**
* Convert on the Dollars screen is enabled only when the user holds another currency with a
* displayable balance. Bugsnag 6ac36f88991833fd09434aad: a Dollars-only user reached the convert
* flow with a leftover zero-balance USDC row as the only "other" holding.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TokenInfoViewModelConvertTest {

@get:Rule
var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher())

private val tokenCoordinator = mockk<TokenCoordinator>(relaxed = true)
private val exchange = mockk<Exchange>(relaxed = true)
private val accountController = mockk<AccountController>(relaxed = true)
private val balances = MutableStateFlow<List<TokenWithBalance>>(emptyList())

private val other = Mint(ByteArray(32) { 2 }.toList())

private fun held(mint: Mint, usd: Double): TokenWithBalance {
val token = mockk<Token>(relaxed = true) { every { address } returns mint }
return mockk(relaxed = true) {
every { this@mockk.token } returns token
every { this@mockk.balance } returns Fiat(usd)
}
}

@Before
fun setUp() {
BottomBarManager.clear()
every { exchange.observePreferredRate() } returns
MutableStateFlow(Rate(fx = 1.0, currency = CurrencyCode.USD))
every { accountController.observeHasAccountFor(any()) } returns MutableStateFlow(false)
every { tokenCoordinator.tokenBalances } returns balances
every { tokenCoordinator.balanceForToken(any<Mint>()) } returns MutableStateFlow(Fiat.Zero)
every { tokenCoordinator.appreciationForToken(any()) } returns MutableStateFlow(Fiat.Zero)
coEvery { tokenCoordinator.getHistoricalMarketCapData(any(), any(), any()) } returns
Result.success(emptyList())
listOf(Mint.usdf, other).forEach { mint ->
val token = mockk<Token>(relaxed = true) { every { address } returns mint }
every { tokenCoordinator.cachedToken(mint) } returns token
coEvery { tokenCoordinator.getTokenMetadata(mint) } returns
Result.success(TokenResult(token, DataSource.Cache))
}
}

@After
fun tearDown() {
BottomBarManager.clear()
}

private fun TestScope.openToken(mint: Mint): TokenInfoViewModel {
val vm = TokenInfoViewModel(
accountController = accountController,
tokenCoordinator = tokenCoordinator,
exchange = exchange,
shareController = mockk<ShareSheetController>(relaxed = true),
resources = FakeResourceHelper(),
purchaseMethodController = mockk<PurchaseMethodController>(relaxed = true),
feedCoordinator = mockk<ActivityFeedCoordinator>(relaxed = true),
dispatchers = TestDispatchers(testScheduler),
)
vm.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint))
advanceUntilIdle()
return vm
}

private fun TestScope.openedRoutes(vm: TokenInfoViewModel): List<AppRoute> {
val routes = mutableListOf<AppRoute>()
vm.eventFlow.filterIsInstance<TokenInfoViewModel.Event.OpenScreen>()
.onEach { routes += it.screen }
.launchIn(backgroundScope)
return routes
}

@Test
fun `Convert from Dollars is disabled while balances load`() = runTest(mainCoroutineRule.dispatcher) {
val vm = openToken(Mint.usdf)
assertFalse(vm.stateFlow.value.canConvert)
}

@Test
fun `Convert from Dollars is disabled when only Dollars is held`() = runTest(mainCoroutineRule.dispatcher) {
balances.value = listOf(held(Mint.usdf, 5.0))
val vm = openToken(Mint.usdf)
val routes = openedRoutes(vm)

assertFalse(vm.stateFlow.value.canConvert)
vm.dispatchEvent(TokenInfoViewModel.Event.OnConvert)
advanceUntilIdle()
assertTrue(routes.isEmpty(), routes.toString())
}

@Test
fun `Convert from Dollars is disabled when the only other holding is empty`() =
runTest(mainCoroutineRule.dispatcher) {
balances.value = listOf(held(Mint.usdf, 5.0), held(other, 0.0))
val vm = openToken(Mint.usdf)
assertFalse(vm.stateFlow.value.canConvert)
}

@Test
fun `Convert from Dollars is enabled when another currency has a balance`() =
runTest(mainCoroutineRule.dispatcher) {
balances.value = listOf(held(Mint.usdf, 5.0), held(other, 2.0))
val vm = openToken(Mint.usdf)
val routes = openedRoutes(vm)

assertTrue(vm.stateFlow.value.canConvert)
vm.dispatchEvent(TokenInfoViewModel.Event.OnConvert)
advanceUntilIdle()
val route = assertIs<AppRoute.Token.Swap>(routes.single())
assertEquals(SwapPurpose.Convert(mint = Mint.usdf, destinationMint = Mint.usdf), route.purpose)
}

@Test
fun `Convert from another currency stays enabled with only Dollars held`() =
runTest(mainCoroutineRule.dispatcher) {
balances.value = listOf(held(Mint.usdf, 5.0))
val vm = openToken(other)
assertTrue(vm.stateFlow.value.canConvert)
}
}
Loading