Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-16 18:10:00 +04:00
parent 2e8d0bc03b
commit 298e7a5565
29 changed files with 687 additions and 43 deletions

View file

@ -47,6 +47,9 @@ internal class InitialCurrenciesResolver @Inject constructor(
* @param userWalletId the wallet to resolve currencies for
* @param initialCryptoCurrency pre-selected currency, or null to auto-select
* @param swapCurrencyPosition preferred position for the initial currency
* @param initialToCryptoCurrency optional currency to pre-select as TO. It is placed into the TO slot
* ONLY if it already exists in the user's crypto portfolio (and the TO slot wasn't filled otherwise);
* if the currency is not added to the wallet, the TO slot stays empty.
* @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null
*/
suspend operator fun invoke(
@ -54,6 +57,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
initialCryptoCurrency: CryptoCurrency?,
swapCurrencyPosition: CurrencyPosition,
isPaymentAccount: Boolean,
initialToCryptoCurrency: CryptoCurrency? = null,
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId)
val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus ->
@ -65,7 +69,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten()
return if (initialCryptoCurrency != null) {
val (from, to) = if (initialCryptoCurrency != null) {
val selectedSwapCurrencyStatus = if (isPaymentAccount) {
cryptoPaymentAccounts
} else {
@ -91,6 +95,43 @@ internal class InitialCurrenciesResolver @Inject constructor(
cryptoCurrencyList = cryptoCurrencyList,
) to null
}
val resolvedTo = to ?: resolveExplicitToCurrency(
initialToCryptoCurrency = initialToCryptoCurrency,
from = from,
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
)
return from to resolvedTo
}
/**
* Resolves the optional explicit TO currency, but only if it is already present in the user's crypto
* portfolio. Matches by token identity ([isSameTokenAs]) rather than full id, since the passed currency
* may come from a different account/derivation. Prefers the instance from the FROM account, then falls
* back to the first match across the portfolio. Never returns the same token as FROM.
*/
private fun resolveExplicitToCurrency(
initialToCryptoCurrency: CryptoCurrency?,
from: SwapCurrencyStatus?,
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
): SwapCurrencyStatus? {
if (initialToCryptoCurrency == null) return null
val fromCurrency = from?.currency
fun matches(status: SwapCurrencyStatus): Boolean {
return status.currency.isSameTokenAs(initialToCryptoCurrency) &&
(fromCurrency == null || !status.currency.isSameTokenAs(fromCurrency))
}
val fromAccountMatch = from?.account?.accountId?.let { fromAccountId ->
cryptoPortfolioAccountsMap.entries
.firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == fromAccountId }
?.value
?.firstOrNull(::matches)
}
return fromAccountMatch ?: cryptoPortfolioAccountsMap.values.flatten().firstOrNull(::matches)
}
/**

View file

@ -170,7 +170,7 @@ internal class SwapModel @Inject constructor(
private val params = paramsContainer.require<SwapComponent.Params>()
private val initialCryptoCurrency = params.cryptoCurrency
private val initialCryptoCurrency = params.fromCryptoCurrency
private val tangemPayInput = params.tangemPayInput
private var isBalanceHidden = true
@ -407,8 +407,9 @@ internal class SwapModel @Inject constructor(
val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = initialCurrenciesResolver(
userWalletId = params.userWalletId,
initialCryptoCurrency = initialCryptoCurrency,
swapCurrencyPosition = params.currencyPosition,
swapCurrencyPosition = params.fromCurrencyPosition,
isPaymentAccount = params.tangemPayInput != null,
initialToCryptoCurrency = params.toCryptoCurrency,
)
preselectedFromCurrency = fromSwapCurrencyStatus?.currency
@ -2316,7 +2317,7 @@ internal class SwapModel @Inject constructor(
modelScope.launch {
val transaction = dataState.getCurrentLoadedSwapState()?.swapDataModel?.transaction
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.cryptoCurrency
val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.fromCryptoCurrency
val fromWalletId = fromSwapCurrencyStatus?.userWalletId ?: params.userWalletId
val network = fromCurrency?.network
val fee = getSelectedSwapFee()?.fee

View file

@ -1151,6 +1151,161 @@ internal class DefaultInitialCurrenciesResolverTest {
// endregion
// region explicit TO currency
@Test
fun `GIVEN explicit TO currency present in portfolio WHEN invoke with position FROM THEN it is placed as TO`() =
runTest {
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialFrom = mockCryptoCurrency(id = fromId)
val accountFrom = mockCryptoCurrency(id = fromId)
// Same token (network + contract), different id instance from the passed one.
val accountTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
val toStatus = createCurrencyStatus(accountTo, fiatAmount = BigDecimal("50"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus, toStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountFrom to true, accountTo to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialFrom,
swapCurrencyPosition = CurrencyPosition.FROM,
isPaymentAccount = false,
initialToCryptoCurrency = explicitTo,
)
assertThat(from?.status).isSameInstanceAs(fromStatus)
assertThat(to?.status).isSameInstanceAs(toStatus)
}
@Test
fun `GIVEN explicit TO currency not present in portfolio WHEN invoke THEN TO stays null`() = runTest {
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialFrom = mockCryptoCurrency(id = fromId)
val accountFrom = mockCryptoCurrency(id = fromId)
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xNOT_IN_WALLET"))
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountFrom to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialFrom,
swapCurrencyPosition = CurrencyPosition.FROM,
isPaymentAccount = false,
initialToCryptoCurrency = explicitTo,
)
assertThat(from?.status).isSameInstanceAs(fromStatus)
assertThat(to).isNull()
}
@Test
fun `GIVEN explicit TO currency is the same token as FROM WHEN invoke THEN TO stays null`() = runTest {
val sharedId = mockCurrencyId("ethereum", "0xUSDT")
val initialFrom = mockCryptoCurrency(id = sharedId)
val accountFrom = mockCryptoCurrency(id = sharedId)
// Passed TO is the same token as FROM (different id instance, same network + contract).
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountFrom to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialFrom,
swapCurrencyPosition = CurrencyPosition.FROM,
isPaymentAccount = false,
initialToCryptoCurrency = explicitTo,
)
assertThat(from?.status).isSameInstanceAs(fromStatus)
assertThat(to).isNull()
}
@Test
fun `GIVEN TO slot already filled by position TO WHEN explicit TO provided THEN explicit TO is ignored`() =
runTest {
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialCurrency = mockCryptoCurrency(id = sharedId)
val accountCurrency = mockCryptoCurrency(id = sharedId)
// A different token that exists in the portfolio and would match the explicit TO.
val otherTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
val otherStatus = createCurrencyStatus(otherTo, fiatAmount = BigDecimal("50"))
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status, otherStatus))
setupSupplier(listOf(accountStatus))
setupAvailability(linkedMapOf(accountCurrency to true, otherTo to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialCurrency,
swapCurrencyPosition = CurrencyPosition.TO,
isPaymentAccount = false,
initialToCryptoCurrency = explicitTo,
)
// Position TO already placed the selected currency in TO; explicit TO must not override it.
assertThat(from).isNull()
assertThat(to?.status).isSameInstanceAs(status)
}
@Test
fun `GIVEN explicit TO currency exists in multiple accounts WHEN invoke THEN instance from FROM account is used`() =
runTest {
// FROM lives in account 1.
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
val initialFrom = mockCryptoCurrency(id = fromId)
val accountFrom = mockCryptoCurrency(id = fromId)
// Same TO token present in both accounts (different id instances).
val toInAccount1 = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
val toInAccount2 = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
val toInAccount1Status = createCurrencyStatus(toInAccount1, fiatAmount = BigDecimal("10"))
val toInAccount2Status = createCurrencyStatus(toInAccount2, fiatAmount = BigDecimal("5000"))
val account1 = createCryptoPortfolioAccountStatus(
currencies = listOf(fromStatus, toInAccount1Status),
derivationIndexValue = 0,
)
val account2 = createCryptoPortfolioAccountStatus(
currencies = listOf(toInAccount2Status),
derivationIndexValue = 1,
)
setupSupplier(listOf(account1, account2))
setupAvailability(linkedMapOf(accountFrom to true, toInAccount1 to true))
setupAvailability(linkedMapOf(toInAccount2 to true))
val (from, to) = resolver.invoke(
userWalletId,
initialCryptoCurrency = initialFrom,
swapCurrencyPosition = CurrencyPosition.FROM,
isPaymentAccount = false,
initialToCryptoCurrency = explicitTo,
)
// TO must be the instance from the FROM account, not the higher-balance duplicate in account 2.
assertThat(from?.status).isSameInstanceAs(fromStatus)
assertThat(to?.status).isSameInstanceAs(toInAccount1Status)
}
// endregion
// region helpers
private fun mockCryptoCurrency(

View file

@ -125,7 +125,7 @@ internal abstract class SwapModelTestBase {
protected fun createParams(): SwapComponent.Params = SwapComponent.Params(
userWalletId = userWalletId,
cryptoCurrency = null,
fromCryptoCurrency = null,
screenSource = "Test",
)