Updated on 2026-08-14
This commit is contained in:
commit
6b9fc4a3ce
1058 changed files with 48197 additions and 12718 deletions
|
|
@ -57,4 +57,11 @@ data class AccountStatusList(
|
|||
groupType = groupType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun AccountStatusList.hasMultiCurrencyAccount(): Boolean = accountStatuses.any { status ->
|
||||
when (status) {
|
||||
is AccountStatus.CryptoPortfolio -> status.tokenList.flattenCurrencies().size > 1
|
||||
is AccountStatus.Payment -> false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.tangem.domain.account.models
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.tokenlist.TokenList
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class AccountStatusListExtTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN no accounts WHEN hasMultiCurrencyAccount THEN returns false`() {
|
||||
val accountList = createAccountStatusList(accountStatuses = emptyList())
|
||||
|
||||
val result = accountList.hasMultiCurrencyAccount()
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN only Payment accounts WHEN hasMultiCurrencyAccount THEN returns false`() {
|
||||
val accountList = createAccountStatusList(
|
||||
accountStatuses = listOf(mockk<AccountStatus.Payment>()),
|
||||
)
|
||||
|
||||
val result = accountList.hasMultiCurrencyAccount()
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN CryptoPortfolio with single currency WHEN hasMultiCurrencyAccount THEN returns false`() {
|
||||
val accountList = createAccountStatusList(
|
||||
accountStatuses = listOf(cryptoPortfolioWithCurrencies(count = 1)),
|
||||
)
|
||||
|
||||
val result = accountList.hasMultiCurrencyAccount()
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN CryptoPortfolio with no currencies WHEN hasMultiCurrencyAccount THEN returns false`() {
|
||||
val accountList = createAccountStatusList(
|
||||
accountStatuses = listOf(cryptoPortfolioWithCurrencies(count = 0)),
|
||||
)
|
||||
|
||||
val result = accountList.hasMultiCurrencyAccount()
|
||||
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN CryptoPortfolio with multiple currencies WHEN hasMultiCurrencyAccount THEN returns true`() {
|
||||
val accountList = createAccountStatusList(
|
||||
accountStatuses = listOf(cryptoPortfolioWithCurrencies(count = 2)),
|
||||
)
|
||||
|
||||
val result = accountList.hasMultiCurrencyAccount()
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN mix of single and multi currency portfolios WHEN hasMultiCurrencyAccount THEN returns true`() {
|
||||
val accountList = createAccountStatusList(
|
||||
accountStatuses = listOf(
|
||||
cryptoPortfolioWithCurrencies(count = 1),
|
||||
cryptoPortfolioWithCurrencies(count = 3),
|
||||
),
|
||||
)
|
||||
|
||||
val result = accountList.hasMultiCurrencyAccount()
|
||||
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
private fun createAccountStatusList(accountStatuses: List<AccountStatus>): AccountStatusList {
|
||||
return mockk {
|
||||
every { this@mockk.accountStatuses } returns accountStatuses
|
||||
}
|
||||
}
|
||||
|
||||
private fun cryptoPortfolioWithCurrencies(count: Int): AccountStatus.CryptoPortfolio {
|
||||
val tokenList = mockk<TokenList> {
|
||||
every { flattenCurrencies() } returns List(count) { mockk<CryptoCurrencyStatus>() }
|
||||
}
|
||||
return mockk {
|
||||
every { this@mockk.tokenList } returns tokenList
|
||||
}
|
||||
}
|
||||
}
|
||||
13
domain/appsflyer/build.gradle.kts
Normal file
13
domain/appsflyer/build.gradle.kts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.domain.appsflyer"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(deps.kotlin.coroutines)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.domain.appsflyer
|
||||
|
||||
enum class AppsFlyerDeeplinkSource {
|
||||
TangemPayHotWalletOnboarding,
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.domain.appsflyer.repository
|
||||
|
||||
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
|
||||
|
||||
interface AppsFlyerRepository {
|
||||
|
||||
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.domain.appsflyer.usecase
|
||||
|
||||
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
|
||||
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
|
||||
|
||||
class ClearAppsFlyerDeeplinkUseCase(
|
||||
private val appsFlyerRepository: AppsFlyerRepository,
|
||||
) {
|
||||
suspend operator fun invoke(source: AppsFlyerDeeplinkSource) {
|
||||
appsFlyerRepository.clearDeeplink(source)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.domain.card.analytics
|
|||
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.CriticalEvent
|
||||
import com.tangem.core.analytics.models.getReferralParams
|
||||
|
||||
sealed class IntroductionProcess(
|
||||
|
|
@ -9,11 +10,17 @@ sealed class IntroductionProcess(
|
|||
params: Map<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent("Introduction Process", event, params) {
|
||||
|
||||
class ScreenOpened : IntroductionProcess("Introduction Process Screen Opened")
|
||||
/**
|
||||
* Tracks the user opening the Introduction Process screen.
|
||||
*/
|
||||
class ScreenOpened : IntroductionProcess("Introduction Process Screen Opened"), CriticalEvent
|
||||
class ButtonTokensList : IntroductionProcess("Button - Tokens List")
|
||||
class ButtonBuyCards : IntroductionProcess("Button - Buy Cards")
|
||||
class ButtonScanCardLegacy : IntroductionProcess("Button - Scan Card")
|
||||
|
||||
/**
|
||||
* Tracks opening the Create Wallet introduction screen.
|
||||
*/
|
||||
class CreateWalletIntroScreenOpened(
|
||||
screenType: ScreenType,
|
||||
referralId: String?,
|
||||
|
|
@ -23,7 +30,7 @@ sealed class IntroductionProcess(
|
|||
put(AnalyticsParam.SCREEN_TYPE, screenType.value)
|
||||
putAll(getReferralParams(referralId))
|
||||
},
|
||||
) {
|
||||
), CriticalEvent {
|
||||
enum class ScreenType(val value: String) {
|
||||
Cold("Cold Wallet"),
|
||||
Hot("Mobile Wallet"),
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ fun CardDTO.supportedBlockchains(
|
|||
private fun CardDTO.isBlockchainUnsupported(blockchain: Blockchain): Boolean {
|
||||
return when (blockchain) {
|
||||
Blockchain.Quai, Blockchain.QuaiTestnet,
|
||||
Blockchain.Adi, Blockchain.AdiTestnet,
|
||||
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet,
|
||||
-> {
|
||||
firmwareVersion <= FirmwareVersion.HDWalletAvailable
|
||||
|
|
|
|||
|
|
@ -217,6 +217,8 @@ data object Wallet2CardConfig : CardConfig {
|
|||
Blockchain.ArbitrumNova -> EllipticCurve.Secp256k1
|
||||
Blockchain.Plasma -> EllipticCurve.Secp256k1
|
||||
Blockchain.PlasmaTestnet -> EllipticCurve.Secp256k1
|
||||
Blockchain.Adi -> EllipticCurve.Secp256k1
|
||||
Blockchain.AdiTestnet -> EllipticCurve.Secp256k1
|
||||
Blockchain.SeiEvm -> EllipticCurve.Secp256k1
|
||||
Blockchain.SeiEvmTestnet -> EllipticCurve.Secp256k1
|
||||
Blockchain.Monad -> EllipticCurve.Secp256k1
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.tangem.TangemSdk
|
|||
import com.tangem.blockchain.common.TransactionSigner
|
||||
import com.tangem.domain.card.models.TwinKey
|
||||
import com.tangem.domain.models.scan.ProductType
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Repository for managing with CardSDK config
|
||||
|
|
@ -28,8 +29,13 @@ interface CardSdkConfigRepository {
|
|||
/** Update the card ID display format according to the [productType] of the scanned card */
|
||||
fun updateCardIdDisplayFormat(productType: ProductType)
|
||||
|
||||
/** Get common signer by [cardId] */
|
||||
fun getCommonSigner(cardId: String?, twinKey: TwinKey?): TransactionSigner
|
||||
/**
|
||||
* Get common signer by [cardId].
|
||||
*
|
||||
* @param userWalletId ID of the user wallet being signed. Used to persist the updated number of signed hashes
|
||||
* back into the wallet after a successful signing operation.
|
||||
*/
|
||||
fun getCommonSigner(cardId: String?, twinKey: TwinKey?, userWalletId: UserWalletId): TransactionSigner
|
||||
|
||||
/** Check if linked terminal is enabled */
|
||||
fun isLinkedTerminal(): Boolean?
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ class Wallet2CardConfigTest {
|
|||
Blockchain.ArbitrumNova to EllipticCurve.Secp256k1,
|
||||
Blockchain.Plasma to EllipticCurve.Secp256k1,
|
||||
Blockchain.PlasmaTestnet to EllipticCurve.Secp256k1,
|
||||
Blockchain.Adi to EllipticCurve.Secp256k1,
|
||||
Blockchain.AdiTestnet to EllipticCurve.Secp256k1,
|
||||
Blockchain.SeiEvm to EllipticCurve.Secp256k1,
|
||||
Blockchain.SeiEvmTestnet to EllipticCurve.Secp256k1,
|
||||
Blockchain.Monad to EllipticCurve.Secp256k1,
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
package com.tangem.domain.dynamicaddresses
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
class DisableDynamicAddressesUseCase(
|
||||
private val dynamicAddressesRepository: DynamicAddressesRepository,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Returns true when consolidation is required before disabling (non-base balances exist),
|
||||
* or false when dynamic addresses were disabled immediately.
|
||||
*/
|
||||
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> =
|
||||
Either.catch {
|
||||
val hasNonBaseBalances = dynamicAddressesRepository.hasNonBaseBalances(userWalletId, network)
|
||||
|
||||
if (!hasNonBaseBalances) {
|
||||
dynamicAddressesRepository.disable(userWalletId, network)
|
||||
return@catch false
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.domain.dynamicaddresses
|
||||
|
||||
import com.tangem.crypto.hdWallet.DerivationNode
|
||||
import com.tangem.crypto.hdWallet.DerivationPath
|
||||
|
||||
/**
|
||||
|
|
@ -11,16 +12,24 @@ import com.tangem.crypto.hdWallet.DerivationPath
|
|||
*/
|
||||
object DynamicAddressesDerivationChecker {
|
||||
|
||||
private const val BIP44_NODE_COUNT = 5
|
||||
const val BIP44_NODE_COUNT = 5
|
||||
private const val ACCOUNT_NODE_COUNT = 3
|
||||
private const val CHANGE_NODE_INDEX = 3
|
||||
private const val ADDRESS_INDEX_NODE_INDEX = 4
|
||||
|
||||
fun parseNodes(path: String): List<DerivationNode>? {
|
||||
return runCatching { DerivationPath(path).nodes }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return `true` if [path] has zero change (node 3) and zero address_index (node 4).
|
||||
*/
|
||||
fun isBaseDerivation(path: String): Boolean {
|
||||
val nodes = runCatching { DerivationPath(path).nodes }.getOrNull() ?: return false
|
||||
val nodes = parseNodes(path) ?: return false
|
||||
return isBaseDerivation(nodes)
|
||||
}
|
||||
|
||||
fun isBaseDerivation(nodes: List<DerivationNode>): Boolean {
|
||||
if (nodes.size < BIP44_NODE_COUNT) return false
|
||||
|
||||
val change = nodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)
|
||||
|
|
|
|||
|
|
@ -4,17 +4,11 @@ import com.tangem.blockchain.common.Blockchain
|
|||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
|
||||
/**
|
||||
* List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode).
|
||||
* Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
|
||||
*
|
||||
* Dynamic addresses are NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses.
|
||||
* Only the default derivation style per blockchain is supported.
|
||||
* Whitelist of blockchains eligible for Dynamic Addresses (XPUB-based multi-address mode).
|
||||
* Mirrors [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
|
||||
*/
|
||||
object DynamicAddressesSupportedBlockchains {
|
||||
|
||||
private const val BIP44_PURPOSE = 44L
|
||||
private const val BIP84_PURPOSE = 84L
|
||||
|
||||
private val supported = setOf(
|
||||
Blockchain.Bitcoin,
|
||||
Blockchain.BitcoinTestnet,
|
||||
|
|
@ -29,26 +23,7 @@ object DynamicAddressesSupportedBlockchains {
|
|||
|
||||
private val supportedNetworkIds = supported.map { it.toNetworkId() }.toSet()
|
||||
|
||||
/**
|
||||
* Allowed BIP purpose nodes per network ID.
|
||||
* BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH).
|
||||
*/
|
||||
private val allowedPurposeByNetworkId: Map<String, Long> = buildMap {
|
||||
put(Blockchain.Bitcoin.toNetworkId(), BIP84_PURPOSE)
|
||||
put(Blockchain.BitcoinTestnet.toNetworkId(), BIP84_PURPOSE)
|
||||
put(Blockchain.Litecoin.toNetworkId(), BIP84_PURPOSE)
|
||||
put(Blockchain.BitcoinCash.toNetworkId(), BIP44_PURPOSE)
|
||||
put(Blockchain.BitcoinCashTestnet.toNetworkId(), BIP44_PURPOSE)
|
||||
put(Blockchain.Dogecoin.toNetworkId(), BIP44_PURPOSE)
|
||||
put(Blockchain.Dash.toNetworkId(), BIP44_PURPOSE)
|
||||
put(Blockchain.Ravencoin.toNetworkId(), BIP44_PURPOSE)
|
||||
put(Blockchain.RavencoinTestnet.toNetworkId(), BIP44_PURPOSE)
|
||||
}
|
||||
|
||||
fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported
|
||||
|
||||
fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds
|
||||
|
||||
/** Returns the allowed BIP purpose node for the given network, or null if not supported */
|
||||
fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId]
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.tangem.domain.dynamicaddresses
|
||||
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker.BIP44_NODE_COUNT
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.derivations.derivationStyleProvider
|
||||
|
||||
/**
|
||||
* Whether the Dynamic Addresses menu entry should be shown for a given (wallet, currency) pair.
|
||||
* Policy check only — hardware XPUB capability is verified by [IsXpubSupportedUseCase].
|
||||
*/
|
||||
class IsDynamicAddressesAvailableUseCase(
|
||||
private val featureToggles: DynamicAddressesFeatureToggles,
|
||||
) {
|
||||
|
||||
operator fun invoke(userWallet: UserWallet, cryptoCurrency: CryptoCurrency): Boolean {
|
||||
if (!featureToggles.isDynamicAddressesEnabled) return false
|
||||
if (cryptoCurrency !is CryptoCurrency.Coin) return false
|
||||
|
||||
val network = cryptoCurrency.network
|
||||
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.rawId)) return false
|
||||
|
||||
return isWalletDefaultDerivation(userWallet, network)
|
||||
}
|
||||
|
||||
private fun isWalletDefaultDerivation(userWallet: UserWallet, network: Network): Boolean {
|
||||
val actualPath = network.derivationPath.value ?: return false
|
||||
val actualNodes = DynamicAddressesDerivationChecker.parseNodes(actualPath) ?: return false
|
||||
if (!DynamicAddressesDerivationChecker.isBaseDerivation(actualNodes)) return false
|
||||
|
||||
val style = userWallet.derivationStyleProvider.getDerivationStyle() ?: return false
|
||||
val expectedPath = network.toBlockchain().derivationPath(style)?.rawPath ?: return false
|
||||
val expectedNodes = DynamicAddressesDerivationChecker.parseNodes(expectedPath) ?: return false
|
||||
if (expectedNodes.size < BIP44_NODE_COUNT) return false
|
||||
|
||||
// Match purpose + coin_type; account is allowed to differ for secondary accounts.
|
||||
return actualNodes[PURPOSE_NODE_INDEX] == expectedNodes[PURPOSE_NODE_INDEX] &&
|
||||
actualNodes[COIN_TYPE_NODE_INDEX] == expectedNodes[COIN_TYPE_NODE_INDEX]
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PURPOSE_NODE_INDEX = 0
|
||||
const val COIN_TYPE_NODE_INDEX = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.domain.dynamicaddresses
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Returns `true` if a consolidation transaction must be broadcast before
|
||||
* [DynamicAddressesRepository.disable] is called (non-base balances exist).
|
||||
*/
|
||||
class IsDynamicAddressesConsolidationRequiredUseCase(
|
||||
private val dynamicAddressesRepository: DynamicAddressesRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> =
|
||||
Either.catch {
|
||||
dynamicAddressesRepository.hasNonBaseBalances(userWalletId, network)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
package com.tangem.domain.dynamicaddresses
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.derivation.DerivationStyle
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
|
||||
import com.tangem.domain.wallets.derivations.derivationStyleProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class IsDynamicAddressesAvailableUseCaseTest {
|
||||
|
||||
private val featureToggles: DynamicAddressesFeatureToggles = mockk()
|
||||
private val useCase = IsDynamicAddressesAvailableUseCase(featureToggles)
|
||||
|
||||
@BeforeAll
|
||||
fun setup() {
|
||||
mockkStatic("com.tangem.domain.wallets.derivations.DerivationStyleProviderExtKt")
|
||||
every { featureToggles.isDynamicAddressesEnabled } returns true
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun teardown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
// region Gating
|
||||
|
||||
@Test
|
||||
fun `feature toggle off returns false`() {
|
||||
every { featureToggles.isDynamicAddressesEnabled } returns false
|
||||
|
||||
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
|
||||
assertThat(result).isFalse()
|
||||
every { featureToggles.isDynamicAddressesEnabled } returns true // restore
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `token currency returns false`() {
|
||||
val token = token(Blockchain.Ethereum, "m/44'/60'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), token)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported network returns false`() {
|
||||
val coin = coin(Blockchain.Ethereum, "m/44'/60'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-HD wallet returns false`() {
|
||||
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(style = null), coin)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region BTC: V2 (Wallet 1) ↔ V3 (Wallet 2 / Hot)
|
||||
|
||||
@Test
|
||||
fun `V3 wallet accepts BIP-84 BTC`() {
|
||||
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `V3 wallet rejects BIP-44 BTC`() {
|
||||
val coin = coin(Blockchain.Bitcoin, "m/44'/0'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `V2 wallet accepts BIP-44 BTC`() {
|
||||
val coin = coin(Blockchain.Bitcoin, "m/44'/0'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `V2 wallet rejects BIP-84 BTC`() {
|
||||
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region LTC: same dual-style behavior
|
||||
|
||||
@Test
|
||||
fun `V3 wallet accepts BIP-84 LTC`() {
|
||||
val coin = coin(Blockchain.Litecoin, "m/84'/2'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `V2 wallet accepts BIP-44 LTC`() {
|
||||
val coin = coin(Blockchain.Litecoin, "m/44'/2'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coin_type mismatch is rejected`() {
|
||||
// BTC coin_type is 0; using LTC's coin_type 2 must fail
|
||||
val coin = coin(Blockchain.Bitcoin, "m/84'/2'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Other supported chains (V2 and V3 share BIP-44)
|
||||
|
||||
@Test
|
||||
fun `V3 wallet accepts BIP-44 Dogecoin`() {
|
||||
val coin = coin(Blockchain.Dogecoin, "m/44'/3'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `V2 wallet accepts BIP-44 Dash`() {
|
||||
val coin = coin(Blockchain.Dash, "m/44'/5'/0'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Account & non-base path
|
||||
|
||||
@Test
|
||||
fun `secondary account is accepted`() {
|
||||
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/3'/0/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-zero change is rejected`() {
|
||||
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/1/0")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-zero address index is rejected`() {
|
||||
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/5")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `path with fewer than 5 nodes is rejected`() {
|
||||
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'")
|
||||
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region helpers
|
||||
|
||||
private fun walletWithStyle(style: DerivationStyle?): UserWallet {
|
||||
val wallet: UserWallet = mockk()
|
||||
val provider = object : DerivationStyleProvider {
|
||||
override fun getDerivationStyle(): DerivationStyle? = style
|
||||
}
|
||||
every { wallet.derivationStyleProvider } returns provider
|
||||
return wallet
|
||||
}
|
||||
|
||||
private fun network(blockchain: Blockchain, derivationPathValue: String): Network {
|
||||
val derivationPath = Network.DerivationPath.Card(derivationPathValue)
|
||||
return Network(
|
||||
id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath),
|
||||
name = blockchain.fullName,
|
||||
currencySymbol = blockchain.currency,
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = blockchain.isTestnet(),
|
||||
standardType = Network.StandardType.Unspecified(blockchain.fullName),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = false,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun coin(blockchain: Blockchain, derivationPathValue: String): CryptoCurrency.Coin {
|
||||
val coin: CryptoCurrency.Coin = mockk()
|
||||
every { coin.network } returns network(blockchain, derivationPathValue)
|
||||
return coin
|
||||
}
|
||||
|
||||
private fun token(blockchain: Blockchain, derivationPathValue: String): CryptoCurrency.Token {
|
||||
val token: CryptoCurrency.Token = mockk()
|
||||
every { token.network } returns network(blockchain, derivationPathValue)
|
||||
return token
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.tangem.domain.express.models
|
||||
|
||||
enum class ProviderFilterType { ALL, CEX, DEX }
|
||||
|
|
@ -24,7 +24,7 @@ dependencies {
|
|||
api(projects.domain.walletManager)
|
||||
api(projects.domain.wallets)
|
||||
api(projects.domain.wallets.models)
|
||||
api(projects.domain.promo)
|
||||
api(projects.domain.stories)
|
||||
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.tokens)
|
||||
|
|
|
|||
|
|
@ -31,8 +31,14 @@ sealed class PaymentAccountStatusValue {
|
|||
is UnderReview,
|
||||
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
|
||||
is Loading -> TotalFiatBalance.Loading
|
||||
is Loaded -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
|
||||
is Deactivated -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
|
||||
is Loaded -> {
|
||||
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
|
||||
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
|
||||
}
|
||||
is Deactivated -> {
|
||||
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
|
||||
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -99,12 +105,30 @@ sealed class PaymentAccountStatusValue {
|
|||
*
|
||||
* @property source The source of the status information.
|
||||
* @property fiatBalance The fiat balance details.
|
||||
* @property cryptoBalance The crypto balance details.
|
||||
* @property cryptoCurrency The crypto currency held by the deactivated account.
|
||||
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
|
||||
* or `null` if the quote is not yet available. When `null`,
|
||||
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
|
||||
*/
|
||||
@Serializable
|
||||
data class Deactivated(
|
||||
override val source: StatusSource,
|
||||
val fiatBalance: FiatBalance,
|
||||
) : PaymentAccountStatusValue()
|
||||
val cryptoBalance: CryptoBalance,
|
||||
val cryptoCurrency: CryptoCurrency.Token,
|
||||
val fiatRate: SerializedBigDecimal?,
|
||||
) : PaymentAccountStatusValue() {
|
||||
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = cryptoCurrency,
|
||||
value = buildCryptoCurrencyStatusValue(
|
||||
amount = cryptoBalance.balance,
|
||||
fiatAmount = fiatBalance.availableBalance,
|
||||
fiatRate = fiatRate,
|
||||
depositAddress = cryptoBalance.depositAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a state where the payment account is successfully loaded with complete information.
|
||||
|
|
@ -116,7 +140,11 @@ sealed class PaymentAccountStatusValue {
|
|||
* @property fiatBalance The fiat balance details.
|
||||
* @property cryptoBalance The crypto balance details.
|
||||
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap (excludes pending/locked funds).
|
||||
* @property cryptoCurrency The crypto currency held by the account.
|
||||
* @property cards The list of user's cards.
|
||||
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
|
||||
* or `null` if the quote is not yet available. When `null`,
|
||||
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
|
||||
*/
|
||||
@Serializable
|
||||
data class Loaded(
|
||||
|
|
@ -129,25 +157,15 @@ sealed class PaymentAccountStatusValue {
|
|||
val availableForWithdrawal: SerializedBigDecimal,
|
||||
val cryptoCurrency: CryptoCurrency.Token,
|
||||
val cards: List<TangemPayCard>,
|
||||
val fiatRate: SerializedBigDecimal?,
|
||||
) : PaymentAccountStatusValue() {
|
||||
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = cryptoCurrency,
|
||||
value = CryptoCurrencyStatus.Loaded(
|
||||
value = buildCryptoCurrencyStatusValue(
|
||||
amount = availableForWithdrawal,
|
||||
fiatAmount = fiatBalance.availableBalance,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
value = cryptoBalance.depositAddress,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
pendingTransactions = emptySet(),
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
fiatRate = fiatRate,
|
||||
depositAddress = cryptoBalance.depositAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -212,6 +230,44 @@ sealed class PaymentAccountStatusValue {
|
|||
)
|
||||
}
|
||||
|
||||
private fun buildCryptoCurrencyStatusValue(
|
||||
amount: SerializedBigDecimal,
|
||||
fiatAmount: SerializedBigDecimal,
|
||||
fiatRate: SerializedBigDecimal?,
|
||||
depositAddress: String,
|
||||
): CryptoCurrencyStatus.Value {
|
||||
val networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
value = depositAddress,
|
||||
),
|
||||
)
|
||||
return if (fiatRate != null) {
|
||||
CryptoCurrencyStatus.Loaded(
|
||||
amount = amount,
|
||||
fiatAmount = fiatAmount,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
networkAddress = networkAddress,
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
pendingTransactions = emptySet(),
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
)
|
||||
} else {
|
||||
CryptoCurrencyStatus.NoQuote(
|
||||
amount = amount,
|
||||
networkAddress = networkAddress,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Loaded.hasCardWithId(cardId: String): Boolean = cards.any { it.id == cardId }
|
||||
|
||||
fun Loaded.findCardWithId(cardId: String): TangemPayCard? = cards.firstOrNull { it.id == cardId }
|
||||
|
|
|
|||
|
|
@ -2,14 +2,20 @@ package com.tangem.domain.models.staking
|
|||
|
||||
import java.math.BigDecimal
|
||||
|
||||
val P2PEthPoolStakingAccount.unstakingAssets: BigDecimal
|
||||
get() = exitQueue.requests.filter { !it.isClaimable }.sumOf { it.totalAssets }
|
||||
|
||||
val P2PEthPoolStakingAccount.withdrawableAssets: BigDecimal
|
||||
get() = exitQueue.requests.filter { it.isClaimable }.sumOf { it.totalAssets }
|
||||
|
||||
fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null): List<StakingBalanceEntry> {
|
||||
return buildList {
|
||||
if (stake.assets > BigDecimal.ZERO) {
|
||||
add(createStakedEntry(vaultAddress, stake.assets, vaultName))
|
||||
}
|
||||
exitQueue.requests.filter { !it.isClaimable }.forEach { add(createUnstakingEntry(vaultAddress, it, vaultName)) }
|
||||
if (availableToWithdraw > BigDecimal.ZERO) {
|
||||
add(createWithdrawableEntry(vaultAddress, availableToWithdraw, vaultName))
|
||||
if (withdrawableAssets > BigDecimal.ZERO) {
|
||||
add(createWithdrawableEntry(vaultAddress, withdrawableAssets, vaultName))
|
||||
}
|
||||
if (stake.totalEarnedAssets > BigDecimal.ZERO) {
|
||||
add(createRewardsEntry(vaultAddress, stake.totalEarnedAssets, vaultName))
|
||||
|
|
|
|||
|
|
@ -62,9 +62,9 @@ sealed interface StakingBalance {
|
|||
|
||||
override val totalRewards: SerializedBigDecimal = accounts.sumOf { it.stake.totalEarnedAssets }
|
||||
|
||||
override val unstakingAmount: SerializedBigDecimal = accounts.sumOf { it.exitQueue.total }
|
||||
override val unstakingAmount: SerializedBigDecimal = accounts.sumOf { it.unstakingAssets }
|
||||
|
||||
override val withdrawableAmount: SerializedBigDecimal = accounts.sumOf { it.availableToWithdraw }
|
||||
override val withdrawableAmount: SerializedBigDecimal = accounts.sumOf { it.withdrawableAssets }
|
||||
|
||||
override val entries: List<StakingBalanceEntry> = accounts.flatMap { it.toStakingBalanceEntries() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,13 @@ fun UserWallet.isImported(): Boolean {
|
|||
}
|
||||
}
|
||||
|
||||
fun UserWallet.isBackedUpForAnalytics(): Boolean {
|
||||
return when (this) {
|
||||
is UserWallet.Cold -> scanResponse.card.backupStatus?.isActive == true
|
||||
is UserWallet.Hot -> backedUp
|
||||
}
|
||||
}
|
||||
|
||||
fun UserWallet.copy(name: String = this.name, walletId: UserWalletId = this.walletId): UserWallet = when (this) {
|
||||
is UserWallet.Cold -> this.copy(name = name, walletId = walletId)
|
||||
is UserWallet.Hot -> this.copy(name = name, walletId = walletId)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ dependencies {
|
|||
api(projects.domain.core)
|
||||
api(projects.domain.settings)
|
||||
implementation(deps.kotlin.serialization)
|
||||
implementation(projects.domain.promo)
|
||||
implementation(projects.domain.stories)
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.coroutine)
|
||||
|
|
|
|||
|
|
@ -6,5 +6,4 @@ enum class OnrampSource(val analyticsName: String) {
|
|||
TOKEN_LONG_TAP("Long Tap"),
|
||||
TOKEN_DETAILS("Token"),
|
||||
MARKETS("Markets"),
|
||||
SEPA_BANNER("SEPA Banner"),
|
||||
}
|
||||
|
|
@ -11,11 +11,9 @@ import com.tangem.domain.onramp.repositories.OnrampRepository
|
|||
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
|
||||
import com.tangem.domain.onramp.utils.calculateRateDif
|
||||
import com.tangem.domain.onramp.utils.compareOffersByRateSpeedAndPriority
|
||||
import com.tangem.domain.promo.PromoRepository
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class GetOnrampOffersUseCase(
|
||||
|
|
@ -23,16 +21,14 @@ class GetOnrampOffersUseCase(
|
|||
private val onrampTransactionRepository: OnrampTransactionRepository,
|
||||
private val errorResolver: OnrampErrorResolver,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val promoRepository: PromoRepository,
|
||||
) {
|
||||
|
||||
operator fun invoke(): EitherFlow<OnrampError, List<OnrampOffersBlock>> {
|
||||
return combine(
|
||||
onrampRepository.getQuotes(),
|
||||
onrampTransactionRepository.getAllTransactions(),
|
||||
flow { emit(promoRepository.isMoonpayPromoActive()) },
|
||||
) { quotes, transactions, isMoonpayPromoActive ->
|
||||
processOffers(quotes, transactions, isMoonpayPromoActive)
|
||||
) { quotes, transactions ->
|
||||
processOffers(quotes, transactions)
|
||||
}
|
||||
.map { offers -> offers.right() }
|
||||
.catch { throwable -> errorResolver.resolve(throwable).left() }
|
||||
|
|
@ -41,7 +37,6 @@ class GetOnrampOffersUseCase(
|
|||
private suspend fun processOffers(
|
||||
quotes: List<OnrampQuote>,
|
||||
transactions: List<OnrampTransaction>,
|
||||
isMoonpayPromoActive: Boolean,
|
||||
): List<OnrampOffersBlock> {
|
||||
val validQuotes = quotes.filterIsInstance<OnrampQuote.Data>()
|
||||
if (validQuotes.isEmpty()) return emptyList()
|
||||
|
|
@ -62,7 +57,7 @@ class GetOnrampOffersUseCase(
|
|||
|
||||
val recentOffer = findRecentOffer(offers, transactions)
|
||||
val bestRateOffer = findBestRateOffer(offers, isGooglePayAvailable)
|
||||
val fastestOffer = findFastestOffer(offers, isGooglePayAvailable, isMoonpayPromoActive)
|
||||
val fastestOffer = findFastestOffer(offers, isGooglePayAvailable)
|
||||
|
||||
return buildOffersBlocks(
|
||||
recentOffer = recentOffer,
|
||||
|
|
@ -90,23 +85,8 @@ class GetOnrampOffersUseCase(
|
|||
return offers.maxWithOrNull(offerComparator(isGooglePayAvailable))
|
||||
}
|
||||
|
||||
private fun findFastestOffer(
|
||||
offers: List<OnrampOffer>,
|
||||
isGooglePayAvailable: Boolean,
|
||||
isMoonpayPromoActive: Boolean,
|
||||
): OnrampOffer? {
|
||||
val moonpayPromoOffers = if (isMoonpayPromoActive) {
|
||||
offers.filter { offer ->
|
||||
offer.quote.provider.id == MOONPAY_PROMO_PROVIDER_ID &&
|
||||
offer.quote.paymentMethod.type == PaymentMethodType.GOOGLE_PAY
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val instantOffers = moonpayPromoOffers.ifEmpty {
|
||||
offers.filter { it.quote.paymentMethod.type.isInstant() }
|
||||
}
|
||||
private fun findFastestOffer(offers: List<OnrampOffer>, isGooglePayAvailable: Boolean): OnrampOffer? {
|
||||
val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() }
|
||||
|
||||
return if (instantOffers.isNotEmpty()) {
|
||||
instantOffers.maxWithOrNull(fastestOfferComparator(isGooglePayAvailable))
|
||||
|
|
@ -308,8 +288,4 @@ class GetOnrampOffersUseCase(
|
|||
-> true
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MOONPAY_PROMO_PROVIDER_ID = "moonpay"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
package com.tangem.domain.onramp
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.onramp.repositories.OnrampRepository
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.onramp.model.OnrampCountry
|
||||
|
||||
class OnrampSepaAvailableUseCase(
|
||||
private val repository: OnrampRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWallet: UserWallet,
|
||||
country: OnrampCountry,
|
||||
cryptoCurrency: CryptoCurrency,
|
||||
): Boolean {
|
||||
if (country.code !in SEPA_AVAILABLE_COUNTRY_CODES) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Either.catch {
|
||||
repository.hasSepaMethod(
|
||||
userWallet = userWallet,
|
||||
country = country,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
)
|
||||
}.getOrElse { false }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SEPA_AVAILABLE_COUNTRY_CODES = listOf(
|
||||
"AL", // Albania
|
||||
"AD", // Andorra
|
||||
"AT", // Austria
|
||||
"BE", // Belgium
|
||||
"BG", // Bulgaria
|
||||
"HR", // Croatia
|
||||
"CY", // Cyprus
|
||||
"CZ", // Czech Republic
|
||||
"DK", // Denmark
|
||||
"EE", // Estonia
|
||||
"FI", // Finland
|
||||
"FR", // France
|
||||
"DE", // Germany
|
||||
"GR", // Greece
|
||||
"HU", // Hungary
|
||||
"IS", // Iceland
|
||||
"IE", // Ireland
|
||||
"IT", // Italy
|
||||
"LV", // Latvia
|
||||
"LI", // Liechtenstein
|
||||
"LT", // Lithuania
|
||||
"LU", // Luxembourg
|
||||
"MT", // Malta
|
||||
"MD", // Moldova
|
||||
"MC", // Monaco
|
||||
"ME", // Montenegro
|
||||
"NL", // Netherlands
|
||||
"MK", // North Macedonia
|
||||
"NO", // Norway
|
||||
"PL", // Poland
|
||||
"PT", // Portugal
|
||||
"RO", // Romania
|
||||
"SM", // San Marino
|
||||
"RS", // Serbia
|
||||
"SK", // Slovakia
|
||||
"SI", // Slovenia
|
||||
"ES", // Spain
|
||||
"SE", // Sweden
|
||||
"CH", // Switzerland
|
||||
"GB", // United Kingdom
|
||||
"VA", // Vatican City
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ interface OnrampRepository {
|
|||
suspend fun getCountriesSync(): List<OnrampCountry>?
|
||||
suspend fun getCountryByIp(userWallet: UserWallet, fromCache: Boolean = false): OnrampCountry
|
||||
suspend fun getStatus(userWallet: UserWallet, txId: String): OnrampStatus
|
||||
suspend fun hasSepaMethod(userWallet: UserWallet, country: OnrampCountry, cryptoCurrency: CryptoCurrency): Boolean
|
||||
suspend fun fetchCurrencies(userWallet: UserWallet)
|
||||
suspend fun fetchCountries(userWallet: UserWallet): List<OnrampCountry>
|
||||
suspend fun fetchPaymentMethodsIfAbsent(userWallet: UserWallet)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import com.tangem.domain.onramp.model.cache.OnrampTransaction
|
|||
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
|
||||
import com.tangem.domain.onramp.repositories.OnrampRepository
|
||||
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
|
||||
import com.tangem.domain.promo.PromoRepository
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -25,7 +24,6 @@ class GetOnrampOffersUseCaseTest {
|
|||
private val errorResolver: OnrampErrorResolver = mockk(relaxUnitFun = true)
|
||||
private val settingsRepository: SettingsRepository = mockk(relaxUnitFun = true)
|
||||
private val cryptoCurrencyId: CryptoCurrency.ID = mockk(relaxUnitFun = true)
|
||||
private val promoRepository: PromoRepository = mockk(relaxUnitFun = true)
|
||||
|
||||
private lateinit var useCase: GetOnrampOffersUseCase
|
||||
|
||||
|
|
@ -37,7 +35,6 @@ class GetOnrampOffersUseCaseTest {
|
|||
onrampTransactionRepository = onrampTransactionRepository,
|
||||
errorResolver = errorResolver,
|
||||
settingsRepository = settingsRepository,
|
||||
promoRepository = promoRepository,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +225,6 @@ class GetOnrampOffersUseCaseTest {
|
|||
|
||||
val transactions = emptyList<OnrampTransaction>()
|
||||
|
||||
coEvery { promoRepository.isMoonpayPromoActive() } returns false
|
||||
coEvery { settingsRepository.isGooglePayAvailability() } returns false
|
||||
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
|
||||
coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf(
|
||||
|
|
@ -249,107 +245,6 @@ class GetOnrampOffersUseCaseTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke should fallback to standard instant offers when promo is active but no Moonpay offers exist`() =
|
||||
runTest {
|
||||
val instantMethod = createMockPaymentMethod("gpay", "Google Pay", PaymentMethodType.GOOGLE_PAY)
|
||||
val slowMethod = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD)
|
||||
val provider = createMockProvider("other", "Other Provider")
|
||||
|
||||
val quotes = listOf(
|
||||
createMockQuote(instantMethod, provider, BigDecimal("95.0")),
|
||||
createMockQuote(slowMethod, provider, BigDecimal("100.0")),
|
||||
)
|
||||
|
||||
val transactions = emptyList<OnrampTransaction>()
|
||||
|
||||
coEvery { promoRepository.isMoonpayPromoActive() } returns true
|
||||
coEvery { settingsRepository.isGooglePayAvailability() } returns true
|
||||
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
|
||||
coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf(transactions)
|
||||
|
||||
val result = useCase()
|
||||
|
||||
result.collect { either ->
|
||||
Truth.assertThat(either.isRight()).isTrue()
|
||||
either.fold(
|
||||
ifLeft = { error -> Truth.assertThat(error).isNull() },
|
||||
ifRight = { offers ->
|
||||
Truth.assertThat(offers).hasSize(1)
|
||||
|
||||
val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended }
|
||||
Truth.assertThat(recommendedBlock).isNotNull()
|
||||
Truth.assertThat(recommendedBlock?.offers).hasSize(2)
|
||||
|
||||
val fastestOffer =
|
||||
recommendedBlock?.offers?.find { it.advantages == OnrampOfferAdvantages.Fastest }
|
||||
Truth.assertThat(fastestOffer).isNotNull()
|
||||
|
||||
when (val quote = fastestOffer?.quote) {
|
||||
is OnrampQuote.Data -> {
|
||||
Truth.assertThat(quote.provider.id).isNotEqualTo("moonpay")
|
||||
Truth.assertThat(quote.paymentMethod.type).isEqualTo(PaymentMethodType.GOOGLE_PAY)
|
||||
}
|
||||
else -> Truth.assertThat(false).isTrue()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke should show Moonpay fastest offer when promo is active`() = runTest {
|
||||
val moonpayGooglePayMethod = createMockPaymentMethod("moonpay-gpay", "Google Pay", PaymentMethodType.GOOGLE_PAY)
|
||||
val otherGooglePayMethod = createMockPaymentMethod(
|
||||
"other-gpay",
|
||||
"Other Google Pay",
|
||||
PaymentMethodType.GOOGLE_PAY,
|
||||
)
|
||||
val slowMethod = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD)
|
||||
val moonpayProvider = createMockProvider("moonpay", "Moonpay")
|
||||
val otherProvider = createMockProvider("other", "Other Provider")
|
||||
|
||||
val quotes = listOf(
|
||||
createMockQuote(moonpayGooglePayMethod, moonpayProvider, BigDecimal("100.0")),
|
||||
createMockQuote(otherGooglePayMethod, otherProvider, BigDecimal("95.0")),
|
||||
createMockQuote(slowMethod, otherProvider, BigDecimal("105.0")),
|
||||
)
|
||||
|
||||
val transactions = emptyList<OnrampTransaction>()
|
||||
|
||||
coEvery { promoRepository.isMoonpayPromoActive() } returns true
|
||||
coEvery { settingsRepository.isGooglePayAvailability() } returns true
|
||||
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
|
||||
coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf(transactions)
|
||||
|
||||
val result = useCase()
|
||||
|
||||
result.collect { either ->
|
||||
Truth.assertThat(either.isRight()).isTrue()
|
||||
either.fold(
|
||||
ifLeft = { error -> Truth.assertThat(error).isNull() },
|
||||
ifRight = { offers ->
|
||||
Truth.assertThat(offers).hasSize(1)
|
||||
|
||||
val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended }
|
||||
Truth.assertThat(recommendedBlock).isNotNull()
|
||||
|
||||
val fastestOffer = recommendedBlock?.offers?.find { it.advantages == OnrampOfferAdvantages.Fastest }
|
||||
Truth.assertThat(fastestOffer).isNotNull()
|
||||
|
||||
when (val quote = fastestOffer?.quote) {
|
||||
is OnrampQuote.Data -> {
|
||||
Truth.assertThat(quote.provider.id).isEqualTo("moonpay")
|
||||
Truth.assertThat(quote.paymentMethod.type).isEqualTo(PaymentMethodType.GOOGLE_PAY)
|
||||
Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0"))
|
||||
}
|
||||
else -> Truth.assertThat(false).isTrue()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMockPaymentMethod(
|
||||
id: String,
|
||||
name: String,
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
package com.tangem.domain.promo.models
|
||||
|
||||
import org.joda.time.DateTime
|
||||
|
||||
data class PromoBanner(
|
||||
val name: String,
|
||||
val bannerState: BannerState,
|
||||
) {
|
||||
|
||||
val isActive = bannerState.status == ACTIVE_STATUS && bannerState.timeline.end.isAfterNow
|
||||
|
||||
data class BannerState(
|
||||
val timeline: Timeline,
|
||||
val status: String,
|
||||
val link: String?,
|
||||
)
|
||||
|
||||
data class Timeline(
|
||||
val start: DateTime,
|
||||
val end: DateTime,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ACTIVE_STATUS = "active"
|
||||
}
|
||||
}
|
||||
|
||||
enum class PromoId {
|
||||
Referral,
|
||||
Sepa,
|
||||
VisaPresale,
|
||||
BlackFriday,
|
||||
OnePlusOne,
|
||||
YieldPromo,
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
package com.tangem.domain.promo
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.models.PromoId
|
||||
import com.tangem.domain.promo.models.StoryContent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface PromoRepository {
|
||||
|
||||
// region Promo
|
||||
fun isReadyToShowWalletPromo(userWalletId: UserWalletId, promoId: PromoId): Flow<Boolean>
|
||||
|
||||
fun isReadyToShowTokenPromo(promoId: PromoId): Flow<Boolean>
|
||||
|
||||
suspend fun setNeverToShowWalletPromo(promoId: PromoId)
|
||||
|
||||
suspend fun setNeverToShowTokenPromo(promoId: PromoId)
|
||||
|
||||
suspend fun isMoonpayPromoActive(): Boolean
|
||||
// endregion
|
||||
|
||||
// region Stories
|
||||
fun getStoryById(id: String): Flow<StoryContent?>
|
||||
|
||||
suspend fun getStoryByIdSync(id: String, refresh: Boolean): StoryContent?
|
||||
|
||||
fun isReadyToShowStories(storyId: String): Flow<Boolean>
|
||||
|
||||
suspend fun isReadyToShowStoriesSync(storyId: String): Boolean
|
||||
|
||||
suspend fun setNeverToShowStories(storyId: String)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
package com.tangem.domain.promo
|
||||
|
||||
import com.tangem.domain.promo.models.PromoId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class ShouldShowPromoTokenUseCase(private val promoRepository: PromoRepository) {
|
||||
|
||||
operator fun invoke(promoId: PromoId): Flow<Boolean> = promoRepository.isReadyToShowTokenPromo(promoId)
|
||||
|
||||
suspend fun neverToShow(promoId: PromoId) = promoRepository.setNeverToShowTokenPromo(promoId)
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
package com.tangem.domain.promo
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.models.PromoId
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.Calendar
|
||||
|
||||
class ShouldShowPromoWalletUseCase(
|
||||
private val promoRepository: PromoRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val isNewPromoBannersEnabled: Boolean,
|
||||
) {
|
||||
|
||||
operator fun invoke(userWalletId: UserWalletId, promoId: PromoId): Flow<Boolean> {
|
||||
if (isNewPromoBannersEnabled) return flowOf(false)
|
||||
|
||||
return flow {
|
||||
emit(false)
|
||||
|
||||
val promoFlow = promoRepository.isReadyToShowWalletPromo(userWalletId, promoId)
|
||||
.map { applyWalletFirstUsageCondition(promoId, it) }
|
||||
|
||||
emitAll(promoFlow)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyWalletFirstUsageCondition(promoId: PromoId, isReady: Boolean): Boolean {
|
||||
if (!isReady) return false
|
||||
|
||||
return when (promoId) {
|
||||
PromoId.Referral,
|
||||
PromoId.VisaPresale,
|
||||
PromoId.BlackFriday,
|
||||
PromoId.OnePlusOne,
|
||||
PromoId.YieldPromo,
|
||||
-> true
|
||||
PromoId.Sepa -> {
|
||||
val walletFirstUsageDate = settingsRepository.getWalletFirstUsageDate()
|
||||
if (walletFirstUsageDate == 0L) return false
|
||||
|
||||
val currentDate = Calendar.getInstance().timeInMillis
|
||||
currentDate - walletFirstUsageDate > ONE_DAY_IN_MILLIS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun neverToShow(promoId: PromoId) = promoRepository.setNeverToShowWalletPromo(promoId)
|
||||
|
||||
private companion object {
|
||||
const val ONE_DAY_IN_MILLIS = 1 * 24 * 60 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package com.tangem.domain.promo
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class ShouldShowStoriesUseCase(private val promoRepository: PromoRepository) {
|
||||
operator fun invoke(storyId: String): Flow<Boolean> = promoRepository.isReadyToShowStories(storyId)
|
||||
suspend fun invokeSync(storyId: String): Boolean = promoRepository.isReadyToShowStoriesSync(storyId)
|
||||
|
||||
suspend fun neverToShow(storyId: String) = promoRepository.setNeverToShowStories(storyId)
|
||||
}
|
||||
24
domain/push-notification-preferences/build.gradle.kts
Normal file
24
domain/push-notification-preferences/build.gradle.kts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.domain.pushnotificationpreferences"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Domain */
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.domain.pushnotificationpreferences
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class ObserveWalletPushNotificationPreferencesUseCase(
|
||||
private val repository: WalletPushNotificationPreferencesRepository,
|
||||
) {
|
||||
|
||||
operator fun invoke(userWalletId: UserWalletId): Flow<WalletPushNotificationPreferences> =
|
||||
repository.observePreferences(userWalletId)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.tangem.domain.pushnotificationpreferences
|
||||
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
|
||||
|
||||
class PreloadWalletPushNotificationPreferencesUseCase(
|
||||
private val repository: WalletPushNotificationPreferencesRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(userWalletId: UserWalletId) = repository.preload(userWalletId)
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.tangem.domain.pushnotificationpreferences
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
|
||||
|
||||
class UpdateWalletPushNotificationPreferenceUseCase(
|
||||
private val repository: WalletPushNotificationPreferencesRepository,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
userWalletId: UserWalletId,
|
||||
category: PushNotificationCategory,
|
||||
isEnabled: Boolean,
|
||||
): Either<Throwable, Unit> = repository.updatePreference(
|
||||
userWalletId = userWalletId,
|
||||
category = category,
|
||||
isEnabled = isEnabled,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.tangem.domain.pushnotificationpreferences.models
|
||||
|
||||
enum class PushNotificationCategory {
|
||||
TransactionAlerts,
|
||||
OffersUpdates,
|
||||
PriceAlerts,
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.domain.pushnotificationpreferences.models
|
||||
|
||||
data class PushNotificationPreference(
|
||||
val isEnabled: Boolean,
|
||||
val isVisible: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.tangem.domain.pushnotificationpreferences.models
|
||||
|
||||
data class WalletPushNotificationPreferences(
|
||||
val transactionAlerts: PushNotificationPreference,
|
||||
val offersUpdates: PushNotificationPreference,
|
||||
val priceAlerts: PushNotificationPreference,
|
||||
)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.domain.pushnotificationpreferences.repository
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
|
||||
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Per-wallet push notification preferences. In-memory cache, not persisted. */
|
||||
interface WalletPushNotificationPreferencesRepository {
|
||||
|
||||
/** Warms up the cache for [userWalletId]. No-op if already cached. */
|
||||
suspend fun preload(userWalletId: UserWalletId)
|
||||
|
||||
fun observePreferences(userWalletId: UserWalletId): Flow<WalletPushNotificationPreferences>
|
||||
|
||||
/** Updates a single [category]; full-replace PUT under the hood. On failure cache is untouched. */
|
||||
suspend fun updatePreference(
|
||||
userWalletId: UserWalletId,
|
||||
category: PushNotificationCategory,
|
||||
isEnabled: Boolean,
|
||||
): Either<Throwable, Unit>
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package com.tangem.domain.staking.model.ethpool
|
||||
|
||||
import com.tangem.domain.models.serialization.SerializedBigDecimal
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* P2P.org transaction broadcast result
|
||||
|
|
@ -9,21 +8,12 @@ import kotlinx.serialization.Serializable
|
|||
*/
|
||||
data class P2PEthPoolBroadcastResult(
|
||||
val hash: String,
|
||||
val status: P2PEthPoolBroadcastStatus,
|
||||
val blockNumber: Int,
|
||||
val transactionIndex: Int,
|
||||
val gasUsed: SerializedBigDecimal,
|
||||
val cumulativeGasUsed: SerializedBigDecimal,
|
||||
val status: String,
|
||||
val blockNumber: Int?,
|
||||
val transactionIndex: Int?,
|
||||
val gasUsed: SerializedBigDecimal?,
|
||||
val cumulativeGasUsed: SerializedBigDecimal?,
|
||||
val effectiveGasPrice: SerializedBigDecimal?,
|
||||
val from: String,
|
||||
val to: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Transaction broadcast status
|
||||
*/
|
||||
@Serializable
|
||||
enum class P2PEthPoolBroadcastStatus {
|
||||
SUCCESS, // Transaction confirmed successfully
|
||||
FAILED, // Transaction failed
|
||||
}
|
||||
)
|
||||
|
|
@ -11,4 +11,9 @@ object P2PEthPoolStakingConfig {
|
|||
|
||||
val activeNetwork: P2PEthPoolNetwork
|
||||
get() = if (USE_TESTNET) P2PEthPoolNetwork.TESTNET else P2PEthPoolNetwork.MAINNET
|
||||
|
||||
/** Vault addresses returned by the backend that should not be shown to users (test/stub vaults). Stored in lowercase. */
|
||||
val TEST_VAULT_ADDRESSES: Set<String> = setOf(
|
||||
"0xb72668d6ff7a0e318f83097a754c6aed0f8af034",
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.domain.staking.model.ethpool
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Per-vault capacity limits from Tangem API /v1/coins/settings.
|
||||
*
|
||||
* @property limit max stakeable amount in ETH (pre-computed as MAX_Threshold - TVL).
|
||||
* Vaults absent from the API response or with null limit are not stored.
|
||||
* @property coefficient threshold multiplier (e.g. 1.25×); optional server-side field,
|
||||
* reserved for future use, not used in client-side calculations
|
||||
*/
|
||||
data class VaultLimitInfo(
|
||||
val limit: BigDecimal,
|
||||
val coefficient: BigDecimal?,
|
||||
)
|
||||
|
|
@ -114,6 +114,7 @@ data class Yield(
|
|||
@Serializable
|
||||
data class Period(
|
||||
val days: Int,
|
||||
val seconds: Int?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class FetchStakingOptionsUseCase(
|
|||
coroutineScope {
|
||||
launch { stakeKitRepository.fetchYields() }
|
||||
launch { p2pEthPoolRepository.fetchVaults() }
|
||||
launch { p2pEthPoolRepository.fetchVaultLimits() }
|
||||
}
|
||||
},
|
||||
catch = { stakingErrorResolver.resolve(it) },
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package com.tangem.domain.staking.model
|
|||
|
||||
sealed class CooldownPeriod {
|
||||
|
||||
data class Fixed(val days: Int) : CooldownPeriod()
|
||||
data class Fixed(val period: Period) : CooldownPeriod()
|
||||
|
||||
data class Range(val minDays: Int, val maxDays: Int) : CooldownPeriod()
|
||||
}
|
||||
|
|
@ -7,7 +7,9 @@ import com.tangem.domain.staking.model.common.RewardSchedule
|
|||
import com.tangem.domain.staking.model.common.StakingActionArgs
|
||||
import com.tangem.domain.staking.model.common.StakingAmountRequirement
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
||||
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* StakingIntegration implementation for P2PEthPool pooled staking.
|
||||
|
|
@ -16,6 +18,7 @@ import java.math.BigDecimal
|
|||
class P2PEthPoolIntegration(
|
||||
override val integrationId: StakingIntegrationID,
|
||||
private val vaults: List<P2PEthPoolVault>,
|
||||
private val vaultLimits: Map<String, VaultLimitInfo>,
|
||||
) : StakingIntegration {
|
||||
|
||||
// Basic
|
||||
|
|
@ -30,9 +33,11 @@ class P2PEthPoolIntegration(
|
|||
vault.toStakingTarget()
|
||||
}
|
||||
|
||||
override val preferredTargets: List<StakingTarget> = targets
|
||||
override val preferredTargets: List<StakingTarget> = vaults
|
||||
.filter { isVaultAvailable(it) }
|
||||
.map { it.toStakingTarget() }
|
||||
|
||||
override val areAllTargetsFull: Boolean = false
|
||||
override val areAllTargetsFull: Boolean = preferredTargets.isEmpty()
|
||||
|
||||
// Enter/Exit Args
|
||||
|
||||
|
|
@ -40,12 +45,12 @@ class P2PEthPoolIntegration(
|
|||
|
||||
override val enterMinimumAmount: BigDecimal = DEFAULT_MINIMUM_STAKE
|
||||
|
||||
override val exitMinimumAmount: BigDecimal? = null
|
||||
override val exitMinimumAmount: BigDecimal = DEFAULT_MINIMUM_UNSTAKE
|
||||
|
||||
override val enterArgs: StakingActionArgs = StakingActionArgs(
|
||||
amountRequirement = StakingAmountRequirement(
|
||||
isRequired = true,
|
||||
minimum = DEFAULT_MINIMUM_STAKE,
|
||||
minimum = enterMinimumAmount,
|
||||
maximum = calculateMaximumStakeAmount(),
|
||||
),
|
||||
isPartialAmountDisabled = false,
|
||||
|
|
@ -54,7 +59,7 @@ class P2PEthPoolIntegration(
|
|||
override val exitArgs: StakingActionArgs = StakingActionArgs(
|
||||
amountRequirement = StakingAmountRequirement(
|
||||
isRequired = true,
|
||||
minimum = null,
|
||||
minimum = exitMinimumAmount,
|
||||
maximum = null,
|
||||
),
|
||||
isPartialAmountDisabled = false,
|
||||
|
|
@ -62,7 +67,7 @@ class P2PEthPoolIntegration(
|
|||
|
||||
// Metadata
|
||||
|
||||
override val warmupPeriodDays: Int = 0
|
||||
override val warmupPeriod: Period = Period.Days(0)
|
||||
|
||||
override val cooldownPeriod: CooldownPeriod = CooldownPeriod.Range(
|
||||
minDays = MIN_COOLDOWN_DAYS,
|
||||
|
|
@ -82,19 +87,28 @@ class P2PEthPoolIntegration(
|
|||
|
||||
override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token
|
||||
|
||||
private fun isVaultAvailable(vault: P2PEthPoolVault): Boolean {
|
||||
val info = vaultLimits[vault.vaultAddress.lowercase()] ?: return false
|
||||
return info.limit - vault.totalAssets > AVAILABILITY_THRESHOLD
|
||||
}
|
||||
|
||||
private fun calculateMaximumStakeAmount(): BigDecimal? {
|
||||
return vaults
|
||||
.filter { isVaultAvailable(it) }
|
||||
.mapNotNull { vault ->
|
||||
val availableCapacity = vault.capacity - vault.totalAssets
|
||||
if (availableCapacity > BigDecimal.ZERO) availableCapacity else null
|
||||
vaultLimits[vault.vaultAddress.lowercase()]?.let { it.limit - vault.totalAssets }
|
||||
}
|
||||
.maxOrNull()
|
||||
.minOrNull()
|
||||
?.setScale(MAX_AMOUNT_SCALE, RoundingMode.FLOOR)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MIN_COOLDOWN_DAYS = 1
|
||||
private const val MAX_COOLDOWN_DAYS = 4
|
||||
private const val MAX_AMOUNT_SCALE = 1
|
||||
private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01")
|
||||
private val DEFAULT_MINIMUM_UNSTAKE = BigDecimal("0.01")
|
||||
private val AVAILABILITY_THRESHOLD = BigDecimal("0.1")
|
||||
|
||||
private const val TERMS_OF_SERVICE_URL = "https://www.p2p.org/terms-of-use"
|
||||
private const val PRIVACY_POLICY_URL = "https://www.p2p.org/privacy-policy"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.domain.staking.model
|
||||
|
||||
sealed class Period {
|
||||
|
||||
abstract val value: Int
|
||||
|
||||
data class Days(
|
||||
override val value: Int,
|
||||
) : Period()
|
||||
|
||||
data class Seconds(
|
||||
override val value: Int,
|
||||
) : Period()
|
||||
}
|
||||
|
|
@ -49,10 +49,22 @@ class StakeKitIntegration(
|
|||
|
||||
// Metadata
|
||||
|
||||
override val warmupPeriodDays: Int = yield.metadata.warmupPeriod.days
|
||||
override val warmupPeriod: Period = yield.metadata.warmupPeriod.let { period ->
|
||||
period.seconds?.let {
|
||||
Period.Seconds(it)
|
||||
} ?: period.days.let {
|
||||
Period.Days(it)
|
||||
}
|
||||
}
|
||||
|
||||
override val cooldownPeriod: CooldownPeriod? = yield.metadata.cooldownPeriod?.days?.let {
|
||||
CooldownPeriod.Fixed(it)
|
||||
override val cooldownPeriod: CooldownPeriod? = yield.metadata.cooldownPeriod?.let { period ->
|
||||
CooldownPeriod.Fixed(
|
||||
period.seconds?.let {
|
||||
Period.Seconds(it)
|
||||
} ?: period.days.let {
|
||||
Period.Days(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override val rewardSchedule: RewardSchedule = yield.metadata.rewardSchedule.toRewardSchedule()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,23 @@ sealed class StakingAvailability {
|
|||
|
||||
data class Available(val option: StakingOption) : StakingAvailability()
|
||||
|
||||
/**
|
||||
* Integration exists and APY is known, but there is no free capacity (all vaults full).
|
||||
* Existing stakes stay visible; new stakes are not offered. P2P ETH only.
|
||||
*/
|
||||
data class Full(val option: StakingOption) : StakingAvailability()
|
||||
|
||||
data object Unavailable : StakingAvailability()
|
||||
|
||||
data object TemporaryUnavailable : StakingAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
/** Staking option if the integration is known (Available or Full), else null. */
|
||||
val StakingAvailability.optionOrNull: StakingOption?
|
||||
get() = when (this) {
|
||||
is StakingAvailability.Available -> option
|
||||
is StakingAvailability.Full -> option
|
||||
StakingAvailability.Unavailable,
|
||||
StakingAvailability.TemporaryUnavailable,
|
||||
-> null
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ sealed interface StakingIntegration {
|
|||
|
||||
// Metadata
|
||||
|
||||
val warmupPeriodDays: Int
|
||||
val warmupPeriod: Period
|
||||
|
||||
val cooldownPeriod: CooldownPeriod?
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork
|
|||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
|
||||
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
|
||||
import com.tangem.domain.staking.model.stakekit.StakingError
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
|
|
@ -131,6 +132,25 @@ interface P2PEthPoolRepository {
|
|||
*/
|
||||
suspend fun getVaultsSync(): List<P2PEthPoolVault>
|
||||
|
||||
/**
|
||||
* Fetch and store vault limits from Tangem API /v1/coins/settings
|
||||
*/
|
||||
suspend fun fetchVaultLimits()
|
||||
|
||||
/**
|
||||
* Get flow of cached vault limits.
|
||||
*
|
||||
* @return Flow of map from vaultAddress.lowercase() to VaultLimitInfo, null if not yet fetched
|
||||
*/
|
||||
fun getVaultLimitsFlow(): Flow<Map<String, VaultLimitInfo>?>
|
||||
|
||||
/**
|
||||
* Get cached vault limits synchronously.
|
||||
*
|
||||
* @return Map from vaultAddress.lowercase() to VaultLimitInfo, null if not yet fetched
|
||||
*/
|
||||
suspend fun getVaultLimitsSyncOrNull(): Map<String, VaultLimitInfo>?
|
||||
|
||||
/**
|
||||
* Check P2PEthPool staking availability by finding public vault
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
package com.tangem.domain.staking.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
|
||||
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class P2PEthPoolIntegrationTest {
|
||||
|
||||
private fun buildVault(
|
||||
address: String,
|
||||
capacity: String,
|
||||
totalAssets: String,
|
||||
) = P2PEthPoolVault(
|
||||
vaultAddress = address,
|
||||
displayName = "Test Vault",
|
||||
apy = BigDecimal("4.5"),
|
||||
baseApy = BigDecimal("4.0"),
|
||||
capacity = BigDecimal(capacity),
|
||||
totalAssets = BigDecimal(totalAssets),
|
||||
feePercent = BigDecimal("0.1"),
|
||||
isPrivate = false,
|
||||
isGenesis = false,
|
||||
isSmoothingPool = true,
|
||||
isErc20 = false,
|
||||
tokenName = null,
|
||||
tokenSymbol = null,
|
||||
createdAt = 0L,
|
||||
)
|
||||
|
||||
private fun buildLimits(vararg pairs: Pair<String, BigDecimal>) =
|
||||
pairs.associate { (addr, limit) ->
|
||||
addr.lowercase() to VaultLimitInfo(limit = limit, coefficient = BigDecimal("1.25"))
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class MaximumAmount {
|
||||
@Test
|
||||
fun `vault available - uses remaining space as max, rounded down to 0_1 ETH`() {
|
||||
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "10"))
|
||||
val limits = buildLimits("0xABC" to BigDecimal("50"))
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
|
||||
|
||||
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isEqualTo(BigDecimal("40.0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remaining with fractional ETH - floored to 0_1 ETH precision`() {
|
||||
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "10"))
|
||||
val limits = buildLimits("0xABC" to BigDecimal("22.37"))
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
|
||||
|
||||
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isEqualTo(BigDecimal("12.3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault absent from limits map - treated as full, max is null`() {
|
||||
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "30"))
|
||||
val limits = emptyMap<String, VaultLimitInfo>()
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
|
||||
|
||||
assertThat(integration.areAllTargetsFull).isTrue()
|
||||
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple available vaults - uses minimum remaining space`() {
|
||||
val vault1 = buildVault("0xA", capacity = "100", totalAssets = "10")
|
||||
val vault2 = buildVault("0xB", capacity = "100", totalAssets = "20")
|
||||
val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50"))
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits)
|
||||
|
||||
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isEqualTo(BigDecimal("30.0"))
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Availability {
|
||||
@Test
|
||||
fun `vault absent from limits - areAllTargetsFull is true`() {
|
||||
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48"))
|
||||
val limits = emptyMap<String, VaultLimitInfo>()
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
|
||||
|
||||
assertThat(integration.areAllTargetsFull).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault with exactly 0_1 ETH remaining - not available`() {
|
||||
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "49.9"))
|
||||
val limits = buildLimits("0xABC" to BigDecimal("50")) // remaining = 0.1
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
|
||||
|
||||
assertThat(integration.areAllTargetsFull).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vault with remaining just above 0_1 ETH - available (regression [REDACTED_TASK_KEY])`() {
|
||||
val vaults = listOf(buildVault("0xABC", capacity = "400", totalAssets = "321.895202388313423922"))
|
||||
val limits = buildLimits("0xABC" to BigDecimal("322.1")) // remaining ≈ 0.2048 > 0.1
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
|
||||
|
||||
assertThat(integration.areAllTargetsFull).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `at least one vault available - areAllTargetsFull is false`() {
|
||||
val vault1 = buildVault("0xA", capacity = "100", totalAssets = "49.95") // full (0.05 remaining < 0.1)
|
||||
val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 0.1)
|
||||
val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50"))
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits)
|
||||
|
||||
assertThat(integration.areAllTargetsFull).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preferred targets only contains available vaults`() {
|
||||
val vault1 = buildVault("0xA", capacity = "100", totalAssets = "49.95") // full (0.05 remaining ≤ 0.1)
|
||||
val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 0.1)
|
||||
val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50"))
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits)
|
||||
|
||||
assertThat(integration.preferredTargets).hasSize(1)
|
||||
assertThat(integration.preferredTargets.first().address).isEqualTo("0xB")
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class MinimumAmount {
|
||||
@Test
|
||||
fun `minimum stake is 0_01 ETH`() {
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, emptyList(), emptyMap())
|
||||
|
||||
assertThat(integration.enterMinimumAmount).isEqualTo(BigDecimal("0.01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minimum unstake is 0_01 ETH`() {
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, emptyList(), emptyMap())
|
||||
|
||||
assertThat(integration.exitMinimumAmount).isEqualTo(BigDecimal("0.01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit args expose minimum unstake requirement of 0_01 ETH`() {
|
||||
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, emptyList(), emptyMap())
|
||||
|
||||
val exitRequirement = integration.exitArgs!!.amountRequirement!!
|
||||
assertThat(exitRequirement.isRequired).isTrue()
|
||||
assertThat(exitRequirement.minimum).isEqualTo(BigDecimal("0.01"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package com.tangem.domain.staking.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.staking.NetworkType
|
||||
import com.tangem.domain.models.staking.YieldToken
|
||||
import com.tangem.domain.staking.model.stakekit.AddressArgument
|
||||
import com.tangem.domain.staking.model.stakekit.Yield
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Tests for [StakeKitIntegration] — specifically the Period/CooldownPeriod mapping from [Yield.Metadata].
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class StakeKitIntegrationTest {
|
||||
|
||||
// region helpers
|
||||
|
||||
private val dummyToken = YieldToken(
|
||||
name = "Test Token",
|
||||
network = NetworkType.SOLANA,
|
||||
symbol = "SOL",
|
||||
decimals = 9,
|
||||
address = null,
|
||||
coinGeckoId = null,
|
||||
logoURI = null,
|
||||
isPoints = false,
|
||||
)
|
||||
|
||||
private val dummyEnter = Yield.Args.Enter(
|
||||
addresses = Yield.Args.Enter.Addresses(
|
||||
address = AddressArgument(required = false),
|
||||
),
|
||||
args = emptyMap(),
|
||||
)
|
||||
|
||||
private val dummyArgs = Yield.Args(enter = dummyEnter, exit = null)
|
||||
|
||||
private val dummyStatus = Yield.Status(enter = true, exit = null)
|
||||
|
||||
private val dummyEnabled = Yield.Metadata.Enabled(enabled = true)
|
||||
|
||||
private fun buildYield(
|
||||
warmupPeriod: Yield.Metadata.Period,
|
||||
cooldownPeriod: Yield.Metadata.Period?,
|
||||
): Yield {
|
||||
return Yield(
|
||||
id = "test-integration",
|
||||
token = dummyToken,
|
||||
tokens = emptyList(),
|
||||
args = dummyArgs,
|
||||
status = dummyStatus,
|
||||
apy = BigDecimal("5.0"),
|
||||
rewardRate = 5.0,
|
||||
rewardType = com.tangem.domain.staking.model.common.RewardType.APY,
|
||||
metadata = Yield.Metadata(
|
||||
name = "Test Staking",
|
||||
logoUri = "https://example.com/logo.png",
|
||||
description = "Test staking integration",
|
||||
documentation = null,
|
||||
gasFeeToken = dummyToken,
|
||||
token = dummyToken,
|
||||
tokens = emptyList(),
|
||||
type = "liquid",
|
||||
rewardSchedule = Yield.Metadata.RewardSchedule.DAY,
|
||||
cooldownPeriod = cooldownPeriod,
|
||||
warmupPeriod = warmupPeriod,
|
||||
rewardClaiming = Yield.Metadata.RewardClaiming.AUTO,
|
||||
defaultValidator = null,
|
||||
minimumStake = null,
|
||||
supportsMultipleValidators = false,
|
||||
revshare = dummyEnabled,
|
||||
fee = dummyEnabled,
|
||||
),
|
||||
validators = emptyList(),
|
||||
isAvailable = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildIntegration(
|
||||
warmupPeriod: Yield.Metadata.Period,
|
||||
cooldownPeriod: Yield.Metadata.Period?,
|
||||
): StakeKitIntegration {
|
||||
return StakeKitIntegration(
|
||||
integrationId = StakingIntegrationID.StakeKit.Coin.Solana,
|
||||
yield = buildYield(warmupPeriod = warmupPeriod, cooldownPeriod = cooldownPeriod),
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class `warmupPeriod mapping` {
|
||||
|
||||
@Test
|
||||
fun `should produce Period Seconds when seconds is non-null`() {
|
||||
// given
|
||||
val warmup = Yield.Metadata.Period(days = 3, seconds = 7200)
|
||||
|
||||
// when
|
||||
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
|
||||
|
||||
// then
|
||||
assertThat(integration.warmupPeriod).isEqualTo(Period.Seconds(7200))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should produce Period Days when seconds is null`() {
|
||||
// given
|
||||
val warmup = Yield.Metadata.Period(days = 5, seconds = null)
|
||||
|
||||
// when
|
||||
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
|
||||
|
||||
// then
|
||||
assertThat(integration.warmupPeriod).isEqualTo(Period.Days(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should prefer seconds over days when both are present`() {
|
||||
// given — days is non-zero but seconds takes priority
|
||||
val warmup = Yield.Metadata.Period(days = 10, seconds = 3600)
|
||||
|
||||
// when
|
||||
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
|
||||
|
||||
// then
|
||||
assertThat(integration.warmupPeriod).isInstanceOf(Period.Seconds::class.java)
|
||||
assertThat((integration.warmupPeriod as Period.Seconds).value).isEqualTo(3600)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should produce Period Days with zero value when days is zero and seconds is null`() {
|
||||
// given
|
||||
val warmup = Yield.Metadata.Period(days = 0, seconds = null)
|
||||
|
||||
// when
|
||||
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
|
||||
|
||||
// then
|
||||
assertThat(integration.warmupPeriod).isEqualTo(Period.Days(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class `cooldownPeriod mapping` {
|
||||
|
||||
@Test
|
||||
fun `should be null when yield cooldownPeriod is null`() {
|
||||
// given
|
||||
val warmup = Yield.Metadata.Period(days = 1, seconds = null)
|
||||
|
||||
// when
|
||||
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
|
||||
|
||||
// then
|
||||
assertThat(integration.cooldownPeriod).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should produce Fixed Period Seconds when cooldown seconds is non-null`() {
|
||||
// given
|
||||
val warmup = Yield.Metadata.Period(days = 1, seconds = null)
|
||||
val cooldown = Yield.Metadata.Period(days = 2, seconds = 86400)
|
||||
|
||||
// when
|
||||
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = cooldown)
|
||||
|
||||
// then
|
||||
assertThat(integration.cooldownPeriod).isEqualTo(CooldownPeriod.Fixed(Period.Seconds(86400)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should produce Fixed Period Days when cooldown seconds is null`() {
|
||||
// given
|
||||
val warmup = Yield.Metadata.Period(days = 1, seconds = null)
|
||||
val cooldown = Yield.Metadata.Period(days = 3, seconds = null)
|
||||
|
||||
// when
|
||||
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = cooldown)
|
||||
|
||||
// then
|
||||
assertThat(integration.cooldownPeriod).isEqualTo(CooldownPeriod.Fixed(Period.Days(3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should prefer seconds over days in cooldown when both are present`() {
|
||||
// given
|
||||
val warmup = Yield.Metadata.Period(days = 1, seconds = null)
|
||||
val cooldown = Yield.Metadata.Period(days = 7, seconds = 604800)
|
||||
|
||||
// when
|
||||
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = cooldown)
|
||||
|
||||
// then
|
||||
val period = integration.cooldownPeriod
|
||||
assertThat(period).isInstanceOf(CooldownPeriod.Fixed::class.java)
|
||||
assertThat((period as CooldownPeriod.Fixed).period).isInstanceOf(Period.Seconds::class.java)
|
||||
assertThat((period.period as Period.Seconds).value).isEqualTo(604800)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ plugins {
|
|||
|
||||
dependencies {
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.promo.models)
|
||||
implementation(projects.domain.stories.models)
|
||||
implementation(projects.domain.settings)
|
||||
implementation(projects.domain.wallets.models)
|
||||
|
||||
|
|
@ -5,5 +5,4 @@ plugins {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(deps.jodatime)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package com.tangem.domain.promo.models
|
||||
package com.tangem.domain.stories.models
|
||||
|
||||
data class StoryContent(
|
||||
val imageHost: String,
|
||||
|
|
@ -25,4 +25,5 @@ data class StoryContent(
|
|||
|
||||
enum class StoryContentIds(val id: String, val analyticType: String) {
|
||||
STORY_FIRST_TIME_SWAP(id = "first-time-swap-v2", analyticType = "Swap"),
|
||||
STORY_FIRST_TIME_YIELD_PROMO(id = "first-time-yield-promo", analyticType = "YieldPromo"),
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
package com.tangem.domain.promo
|
||||
package com.tangem.domain.stories
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.domain.promo.models.StoryContent
|
||||
import com.tangem.domain.promo.models.StoryContentIds
|
||||
import com.tangem.domain.stories.models.StoryContent
|
||||
import com.tangem.domain.stories.models.StoryContentIds
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
|
|
@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.*
|
|||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class GetStoryContentUseCase(
|
||||
private val promoRepository: PromoRepository,
|
||||
private val storiesRepository: StoriesRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ class GetStoryContentUseCase(
|
|||
return isFCAAllowed(id).transform { isAllowed ->
|
||||
if (isAllowed) {
|
||||
emitAll(
|
||||
promoRepository.getStoryById(id)
|
||||
storiesRepository.getStoryById(id)
|
||||
.map<StoryContent?, Either<Throwable, StoryContent?>> { it.right() }
|
||||
.catch { emit(it.left()) }
|
||||
.onEmpty { emit(null.right()) },
|
||||
|
|
@ -34,7 +34,7 @@ class GetStoryContentUseCase(
|
|||
suspend fun invokeSync(id: String, refresh: Boolean = false): Either<Throwable, StoryContent?> = Either.catch {
|
||||
val isFCAAllowed = isFCAAllowed(id).firstOrNull() ?: false
|
||||
return@catch if (isFCAAllowed) {
|
||||
promoRepository.getStoryByIdSync(id, refresh)
|
||||
storiesRepository.getStoryByIdSync(id, refresh)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.domain.stories
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class ShouldShowStoriesUseCase(private val storiesRepository: StoriesRepository) {
|
||||
operator fun invoke(storyId: String): Flow<Boolean> = storiesRepository.isReadyToShowStories(storyId)
|
||||
suspend fun invokeSync(storyId: String): Boolean = storiesRepository.isReadyToShowStoriesSync(storyId)
|
||||
|
||||
suspend fun neverToShow(storyId: String) = storiesRepository.setNeverToShowStories(storyId)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.domain.stories
|
||||
|
||||
import com.tangem.domain.stories.models.StoryContent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface StoriesRepository {
|
||||
|
||||
// region Stories
|
||||
fun getStoryById(id: String): Flow<StoryContent?>
|
||||
|
||||
suspend fun getStoryByIdSync(id: String, refresh: Boolean): StoryContent?
|
||||
|
||||
fun isReadyToShowStories(storyId: String): Flow<Boolean>
|
||||
|
||||
suspend fun isReadyToShowStoriesSync(storyId: String): Boolean
|
||||
|
||||
suspend fun setNeverToShowStories(storyId: String)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -30,4 +30,8 @@ dependencies {
|
|||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.jodatime)
|
||||
|
||||
/** Tests */
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(deps.test.mockk)
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.domain.swap.models
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
enum class PredefinedPercentAmount(val percent: BigDecimal) {
|
||||
PERCENT_25(BigDecimal("0.25")),
|
||||
PERCENT_50(BigDecimal("0.50")),
|
||||
PERCENT_75(BigDecimal("0.75")),
|
||||
MAX(BigDecimal.ONE),
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.domain.swap.usecase
|
||||
|
||||
import com.tangem.domain.swap.models.PredefinedPercentAmount
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
class CalculateAmountUseCase {
|
||||
|
||||
operator fun invoke(balance: BigDecimal, decimals: Int, percent: PredefinedPercentAmount): BigDecimal {
|
||||
return balance
|
||||
.multiply(percent.percent)
|
||||
.setScale(decimals, RoundingMode.DOWN)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package com.tangem.domain.swap.usecase
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.swap.models.PredefinedPercentAmount
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
class CalculateAmountUseCaseTest {
|
||||
|
||||
private val useCase = CalculateAmountUseCase()
|
||||
|
||||
@Test
|
||||
fun `GIVEN balance and PERCENT_25 WHEN invoke THEN return one quarter of balance`() {
|
||||
val balance = BigDecimal("100")
|
||||
val decimals = 2
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.PERCENT_25,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("25.00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN balance and PERCENT_50 WHEN invoke THEN return half of balance`() {
|
||||
val balance = BigDecimal("100")
|
||||
val decimals = 2
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.PERCENT_50,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("50.00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN balance and PERCENT_75 WHEN invoke THEN return three quarters of balance`() {
|
||||
val balance = BigDecimal("100")
|
||||
val decimals = 2
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.PERCENT_75,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("75.00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN balance and MAX WHEN invoke THEN return full balance`() {
|
||||
val balance = BigDecimal("100")
|
||||
val decimals = 2
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.MAX,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("100.00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero balance WHEN invoke THEN return zero with decimals scale`() {
|
||||
val balance = BigDecimal.ZERO
|
||||
val decimals = 6
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.PERCENT_50,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("0.000000"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN balance with more precision than decimals WHEN invoke THEN truncate result with rounding down`() {
|
||||
val balance = BigDecimal("1.999999999999999999")
|
||||
val decimals = 6
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.PERCENT_25,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("0.499999"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fractional percent product WHEN invoke THEN round down to decimals scale`() {
|
||||
val balance = BigDecimal("1")
|
||||
val decimals = 1
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.PERCENT_75,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("0.7"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero decimals WHEN invoke THEN return integer value rounded down`() {
|
||||
val balance = BigDecimal("9")
|
||||
val decimals = 0
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.PERCENT_75,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("6"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN high-precision balance and MAX WHEN invoke THEN preserve balance truncated to decimals`() {
|
||||
val balance = BigDecimal("12.3456789012345678")
|
||||
val decimals = 8
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.MAX,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("12.34567890"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN large balance and PERCENT_50 WHEN invoke THEN return correctly scaled half`() {
|
||||
val balance = BigDecimal("123456789.987654321")
|
||||
val decimals = 4
|
||||
|
||||
val result = useCase(
|
||||
balance = balance,
|
||||
decimals = decimals,
|
||||
percent = PredefinedPercentAmount.PERCENT_50,
|
||||
)
|
||||
|
||||
assertThat(result).isEqualTo(BigDecimal("61728394.9938"))
|
||||
}
|
||||
}
|
||||
|
|
@ -34,8 +34,8 @@ dependencies {
|
|||
implementation(projects.domain.settings)
|
||||
implementation(projects.features.swap.domain.api)
|
||||
implementation(projects.features.swap.domain.models)
|
||||
implementation(projects.domain.promo.models)
|
||||
implementation(projects.domain.promo)
|
||||
implementation(projects.domain.stories.models)
|
||||
implementation(projects.domain.stories)
|
||||
implementation(projects.domain.networks)
|
||||
implementation(projects.domain.quotes)
|
||||
implementation(projects.domain.yieldSupply.models)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ dependencies {
|
|||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.txhistory.models)
|
||||
implementation(projects.domain.staking.models)
|
||||
implementation(projects.domain.promo.models)
|
||||
implementation(projects.domain.stories.models)
|
||||
|
||||
/** Other dependencies */
|
||||
implementation(deps.kotlin.serialization)
|
||||
|
|
|
|||
|
|
@ -61,4 +61,7 @@ sealed class ScenarioUnavailabilityReason {
|
|||
enum class WithdrawalScenario {
|
||||
SELL, SEND // TODO staking create&process STAKING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ScenarioUnavailabilityReason.isLoading: Boolean
|
||||
get() = this == ScenarioUnavailabilityReason.DataLoading || this is ScenarioUnavailabilityReason.ExpressLoading
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package com.tangem.domain.tokens.model.analytics
|
||||
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
|
||||
sealed class PromoAnalyticsEvent(
|
||||
event: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
) : AnalyticsEvent(category = "Promotion", event = event, params = params) {
|
||||
data class NoticePromotionBanner(
|
||||
private val source: AnalyticsParam.ScreensSources,
|
||||
private val program: Program,
|
||||
) : PromoAnalyticsEvent(
|
||||
event = "Notice - Promotion Banner",
|
||||
params = mapOf(
|
||||
AnalyticsParam.SOURCE to source.value,
|
||||
"Program Name" to program.programName,
|
||||
),
|
||||
)
|
||||
|
||||
data class PromotionBannerClicked(
|
||||
private val source: AnalyticsParam.ScreensSources,
|
||||
private val program: Program,
|
||||
private val action: BannerAction,
|
||||
) : PromoAnalyticsEvent(
|
||||
event = "Promo Banner Clicked",
|
||||
params = mapOf(
|
||||
AnalyticsParam.SOURCE to source.value,
|
||||
"Program Name" to program.programName,
|
||||
"Action" to action.action,
|
||||
),
|
||||
) {
|
||||
sealed class BannerAction(val action: String) {
|
||||
class Clicked : BannerAction(action = "Clicked")
|
||||
class Closed : BannerAction(action = "Closed")
|
||||
}
|
||||
}
|
||||
|
||||
// region visa waitlist promo
|
||||
class VisaWaitlistPromo : PromoAnalyticsEvent(event = "Visa Waitlist")
|
||||
|
||||
class VisaWaitlistPromoJoin : PromoAnalyticsEvent(
|
||||
event = "Button - Join Now",
|
||||
params = mapOf(
|
||||
"Program Name" to "Visa Waitlist",
|
||||
),
|
||||
)
|
||||
|
||||
class VisaWaitlistPromoDismiss : PromoAnalyticsEvent(
|
||||
event = "Button - Close",
|
||||
params = mapOf(
|
||||
"Program Name" to "Visa Waitlist",
|
||||
),
|
||||
)
|
||||
//endregion
|
||||
|
||||
// Use it on new promo action
|
||||
enum class Program(val programName: String) {
|
||||
Empty("Empty"),
|
||||
Sepa("Sepa"),
|
||||
BlackFriday("Black Friday"),
|
||||
OnePlusOne("One-Plus-One"),
|
||||
YieldPromo("Yield Promo"),
|
||||
}
|
||||
}
|
||||
|
|
@ -22,14 +22,18 @@ sealed class TokenScreenAnalyticsEvent(
|
|||
blockchain: String,
|
||||
token: String,
|
||||
tokenBalance: TokenBalance,
|
||||
isDynamicAddress: Boolean? = null,
|
||||
) : AnalyticsEvent(
|
||||
category = "Details Screen",
|
||||
event = "Details Screen Opened",
|
||||
params = mapOf(
|
||||
BLOCKCHAIN to blockchain,
|
||||
TOKEN_PARAM to token,
|
||||
BALANCE to tokenBalance.name,
|
||||
),
|
||||
params = buildMap {
|
||||
put(BLOCKCHAIN, blockchain)
|
||||
put(TOKEN_PARAM, token)
|
||||
put(BALANCE, tokenBalance.name)
|
||||
isDynamicAddress?.let {
|
||||
put("Dynamic Address", if (it) "True" else "False")
|
||||
}
|
||||
},
|
||||
) {
|
||||
sealed class TokenBalance(val name: String) {
|
||||
data object Full : TokenBalance("Full")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package com.tangem.domain.tokens.model.warnings
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.promo.models.PromoId
|
||||
import org.joda.time.DateTime
|
||||
import java.math.BigDecimal
|
||||
|
||||
sealed class CryptoCurrencyWarning {
|
||||
|
|
@ -47,12 +45,6 @@ sealed class CryptoCurrencyWarning {
|
|||
val cryptoCurrency: CryptoCurrency,
|
||||
) : CryptoCurrencyWarning()
|
||||
|
||||
data class SwapPromo(
|
||||
val promoId: PromoId,
|
||||
val startDateTime: DateTime,
|
||||
val endDateTime: DateTime,
|
||||
) : CryptoCurrencyWarning()
|
||||
|
||||
data object BeaconChainShutdown : CryptoCurrencyWarning()
|
||||
|
||||
data object MigrationMaticToPol : CryptoCurrencyWarning()
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ class BalanceFetchingOperations(
|
|||
async {
|
||||
val result = when (source) {
|
||||
FetchingSource.NETWORK -> fetchNetworks(userWalletId, currencies)
|
||||
FetchingSource.QUOTE -> fetchQuotes(currencies)
|
||||
FetchingSource.QUOTE -> fetchQuotes(
|
||||
currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId },
|
||||
)
|
||||
FetchingSource.STAKING -> fetchStaking(userWalletId, currencies)
|
||||
}
|
||||
source to result
|
||||
|
|
@ -85,17 +87,14 @@ class BalanceFetchingOperations(
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetches quotes for the given currencies.
|
||||
* Fetches quotes for the given raw currency ids.
|
||||
*
|
||||
* @param currencies the cryptocurrencies to fetch quotes for
|
||||
* @param rawCurrencyIds the raw currency ids to fetch quotes for
|
||||
* @return Either with Unit on success or Throwable on failure
|
||||
*/
|
||||
suspend fun fetchQuotes(currencies: Collection<CryptoCurrency>): Either<Throwable, Unit> {
|
||||
suspend fun fetchQuotes(rawCurrencyIds: Set<CryptoCurrency.RawID>): Either<Throwable, Unit> {
|
||||
return multiQuoteStatusFetcher(
|
||||
params = MultiQuoteStatusFetcher.Params(
|
||||
currenciesIds = currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId },
|
||||
appCurrencyId = null,
|
||||
),
|
||||
params = MultiQuoteStatusFetcher.Params(currenciesIds = rawCurrencyIds, appCurrencyId = null),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.promo.PromoRepository
|
||||
import com.tangem.domain.promo.models.StoryContent
|
||||
import com.tangem.domain.promo.models.StoryContentIds
|
||||
import com.tangem.domain.stories.StoriesRepository
|
||||
import com.tangem.domain.stories.models.StoryContent
|
||||
import com.tangem.domain.stories.models.StoryContentIds
|
||||
import com.tangem.domain.staking.model.StakingAvailability
|
||||
import com.tangem.domain.staking.repositories.StakingRepository
|
||||
import com.tangem.domain.tokens.actions.CommonActionsFactory
|
||||
|
|
@ -28,14 +28,14 @@ import kotlinx.coroutines.flow.*
|
|||
* @param rampManager the manager for handling ramp state operations
|
||||
* @param walletManagersFacade the facade for managing wallet operations
|
||||
* @property stakingRepository the repository for staking-related data
|
||||
* @property promoRepository the repository for promotional content
|
||||
* @property storiesRepository the repository for stories content
|
||||
* @property dispatchers the coroutine dispatcher provider for managing concurrency
|
||||
*/
|
||||
class GetCryptoCurrencyActionsUseCase(
|
||||
rampManager: RampStateManager,
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
private val stakingRepository: StakingRepository,
|
||||
private val promoRepository: PromoRepository,
|
||||
private val storiesRepository: StoriesRepository,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
|
|
@ -127,7 +127,7 @@ class GetCryptoCurrencyActionsUseCase(
|
|||
}
|
||||
|
||||
private fun getSwapStoryContent(): Flow<StoryContent?> {
|
||||
return promoRepository.getStoryById(StoryContentIds.STORY_FIRST_TIME_SWAP.id)
|
||||
return storiesRepository.getStoryById(StoryContentIds.STORY_FIRST_TIME_SWAP.id)
|
||||
.conflate()
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,17 +174,18 @@ internal open class BaseActionsFactory(
|
|||
protected fun createStakingAction(
|
||||
currency: CryptoCurrency,
|
||||
stakingAvailability: StakingAvailability,
|
||||
): ActionState.Stake {
|
||||
return if (stakingAvailability is StakingAvailability.Available) {
|
||||
ActionState.Stake(
|
||||
): ActionState.Stake? {
|
||||
return when (stakingAvailability) {
|
||||
is StakingAvailability.Available -> ActionState.Stake(
|
||||
unavailabilityReason = ScenarioUnavailabilityReason.None,
|
||||
option = stakingAvailability.option,
|
||||
)
|
||||
} else {
|
||||
ActionState.Stake(
|
||||
StakingAvailability.TemporaryUnavailable -> ActionState.Stake(
|
||||
unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(currency.name),
|
||||
option = null,
|
||||
)
|
||||
is StakingAvailability.Full -> null
|
||||
StakingAvailability.Unavailable -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ internal class CommonActionsFactory(
|
|||
|
||||
// region Stake
|
||||
createStakingAction(currency = cryptoCurrencyStatus.currency, stakingAvailability = stakingAvailability)
|
||||
.addByReason()
|
||||
?.addByReason()
|
||||
// endregion
|
||||
|
||||
val sendUnavailabilityReason = sendUnavailabilityReasonDeferred.await()
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ internal class OutdatedDataActionsFactory(
|
|||
stakingAvailability = stakingAvailability,
|
||||
)
|
||||
|
||||
stakingAction.addByReason()
|
||||
stakingAction?.addByReason()
|
||||
} else {
|
||||
val stakingAction = ActionState.Stake(
|
||||
unavailabilityReason = ScenarioUnavailabilityReason.UsedOutdatedData,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
|
||||
import com.tangem.domain.pay.TangemPayCurrencyFactory
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.staking.StakingIdFactory
|
||||
|
|
@ -173,6 +174,7 @@ class WalletBalanceFetcher internal constructor(
|
|||
|
||||
// Fetch TangemPay separately — may run long-polling, so it must not block balance error checking
|
||||
if (fetchingSources.any { it is WalletFetchingSource.TangemPay }) {
|
||||
balanceFetchingOperations.fetchQuotes(rawCurrencyIds = setOf(TangemPayCurrencyFactory.TOKEN_ID))
|
||||
paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,14 +65,15 @@ class AssociateAssetUseCase(
|
|||
private fun createSigner(userWallet: UserWallet): TransactionSigner {
|
||||
return when (userWallet) {
|
||||
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
|
||||
is UserWallet.Cold -> getColdSigner()
|
||||
is UserWallet.Cold -> getColdSigner(userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getColdSigner(): TransactionSigner {
|
||||
private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
|
||||
return cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = null,
|
||||
twinKey = null, // use null here because no assets support for Twin cards
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import com.tangem.blockchain.common.AmountType
|
|||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.demo.DemoTransactionSender
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.mapToFeeError
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -62,14 +62,15 @@ class OpenTrustlineUseCase(
|
|||
private fun createSigner(userWallet: UserWallet): TransactionSigner {
|
||||
return when (userWallet) {
|
||||
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
|
||||
is UserWallet.Cold -> getColdSigner()
|
||||
is UserWallet.Cold -> getColdSigner(userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getColdSigner(): TransactionSigner {
|
||||
private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
|
||||
return cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = null,
|
||||
twinKey = null, // use null here because no assets support for Twin cards
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ class PrepareAndSignUseCase(
|
|||
val signer = cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
return signer
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ class PrepareForSendUseCase(
|
|||
val signer = cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
return signer
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,14 +58,15 @@ class RetryIncompleteTransactionUseCase(
|
|||
private fun createSigner(userWallet: UserWallet): TransactionSigner {
|
||||
return when (userWallet) {
|
||||
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
|
||||
is UserWallet.Cold -> getColdSigner()
|
||||
is UserWallet.Cold -> getColdSigner(userWallet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getColdSigner(): TransactionSigner {
|
||||
private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
|
||||
return cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = null,
|
||||
twinKey = null, // use null here because no assets support for Twin cards
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ class SendLargeSolanaTransactionUseCase(
|
|||
val signer = cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
|
||||
val walletManager = walletManagersFacade
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ class SendTransactionUseCase(
|
|||
val coldSigner = cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
|
||||
coldSigner
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class SignCloreMessageUseCase(
|
|||
cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = null,
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
}
|
||||
is UserWallet.Hot -> getHotWalletSigner(userWallet)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class SignUseCase(
|
|||
return cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -235,6 +235,7 @@ class CreateAndSendGaslessTransactionUseCase(
|
|||
cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
}
|
||||
is UserWallet.Hot -> getHotWalletSigner(userWallet)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ dependencies {
|
|||
implementation(projects.domain.core)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.core.datasource)
|
||||
|
||||
/** Security */
|
||||
implementation(deps.spongecastle.core)
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
package com.tangem.domain.pay
|
||||
|
||||
import com.tangem.domain.models.account.CardDisplayName
|
||||
import com.tangem.domain.visa.model.TangemPayCardFrozenState
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TangemPayDetailsConfig(
|
||||
val customerId: String,
|
||||
val cardId: String,
|
||||
val isPinSet: Boolean,
|
||||
val cardFrozenState: TangemPayCardFrozenState,
|
||||
val cardNumberEnd: String,
|
||||
val isReissuing: Boolean,
|
||||
val chainId: Int,
|
||||
val isTangemPayDeactivated: Boolean,
|
||||
val displayName: CardDisplayName?,
|
||||
)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.domain.visa.model
|
||||
|
||||
enum class TangemPayPushNotificationType(val value: String) {
|
||||
CARD_READY("card_ready"),
|
||||
TRANSACTION_SPEND("transaction_spend"),
|
||||
DECLINED_TOP_UP("declined_top_up"),
|
||||
COLLATERAL_WITHDRAW("collateral_withdraw"),
|
||||
COLLATERAL_DEPOSIT("collateral_deposit"),
|
||||
TRANSACTION_SPEND_REFUND("transaction_spend_refund"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
private val map = entries.associateBy { it.value }
|
||||
|
||||
val all: Set<String> = entries.map { it.value }.toSet()
|
||||
|
||||
fun fromValue(value: String): TangemPayPushNotificationType? = map[value]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
package com.tangem.domain.pay
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
||||
@Deprecated("TangemPayCurrencyFactory")
|
||||
interface TangemPayCryptoCurrencyFactory {
|
||||
|
||||
fun create(userWallet: UserWallet, chainId: Int): Either<UniversalError, CryptoCurrency>
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.tangem.domain.pay
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Factory that builds the [CryptoCurrency.Token] used by Tangem Pay (USDC on Polygon) for a given user wallet.
|
||||
*
|
||||
* Replaces the deprecated `TangemPayCryptoCurrencyFactory`: callers no longer pass the chain id explicitly —
|
||||
* the underlying network is resolved from the wallet.
|
||||
*/
|
||||
interface TangemPayCurrencyFactory {
|
||||
|
||||
/**
|
||||
* Builds the Tangem Pay token bound to the network of the wallet identified by [userWalletId].
|
||||
*
|
||||
* @throws IllegalStateException if no wallet with [userWalletId] is currently loaded.
|
||||
*/
|
||||
fun create(userWalletId: UserWalletId): CryptoCurrency.Token
|
||||
|
||||
/** Hardcoded token metadata for the Tangem Pay currency (USDC on Polygon). */
|
||||
companion object {
|
||||
/** CoinGecko-style raw id used to query quotes for the Tangem Pay token. */
|
||||
val TOKEN_ID = CryptoCurrency.RawID("usd-coin")
|
||||
const val TOKEN_NAME = "USDC"
|
||||
const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
|
||||
const val TOKEN_DECIMALS = 6
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimit
|
||||
import com.tangem.domain.models.account.CardDisplayName
|
||||
import com.tangem.domain.models.account.PaymentAccountStatusValue
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.pay.TangemPayCardLimit
|
||||
import com.tangem.domain.visa.model.TangemPayCardFrozenState
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
|
|
@ -27,6 +27,7 @@ data class CustomerInfo(
|
|||
val cardInfo: CardInfo?,
|
||||
val state: State,
|
||||
val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
|
||||
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?,
|
||||
) {
|
||||
enum class State {
|
||||
NEW,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import arrow.core.Either
|
|||
import com.tangem.core.error.UniversalError
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
|
||||
import com.tangem.domain.pay.WithdrawalResult
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -18,7 +19,7 @@ interface TangemPayWithdrawRepository {
|
|||
exchangeData: TangemPayWithdrawExchangeState,
|
||||
): Either<UniversalError, WithdrawalResult>
|
||||
|
||||
suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean
|
||||
suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean
|
||||
|
||||
suspend fun pollWithdrawOrdersIfNeeds(userWallet: UserWallet)
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.domain.pay.utils
|
||||
|
||||
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
object TangemPayTxHistoryItemStatusConverter : Converter<String, TangemPayTxHistoryItem.Status> {
|
||||
override fun convert(value: String): TangemPayTxHistoryItem.Status {
|
||||
return when (value.uppercase()) {
|
||||
"PENDING" -> TangemPayTxHistoryItem.Status.PENDING
|
||||
"RESERVED" -> TangemPayTxHistoryItem.Status.RESERVED
|
||||
"COMPLETED" -> TangemPayTxHistoryItem.Status.COMPLETED
|
||||
"DECLINED" -> TangemPayTxHistoryItem.Status.DECLINED
|
||||
"REVERSED" -> TangemPayTxHistoryItem.Status.REVERSED
|
||||
else -> TangemPayTxHistoryItem.Status.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue