diff --git a/domain/staking/build.gradle.kts b/domain/staking/build.gradle.kts index efe7186cb3..770bfe4838 100644 --- a/domain/staking/build.gradle.kts +++ b/domain/staking/build.gradle.kts @@ -9,6 +9,10 @@ android { namespace = "com.tangem.domain.staking" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { api(projects.domain.staking.models) api(projects.domain.core) @@ -30,4 +34,13 @@ dependencies { exclude(module = "joda-time") } implementation(projects.libs.crypto) + implementation(projects.libs.blockchainSdk) + + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(tangemDeps.card.core) + testImplementation(projects.common.test) } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt b/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt new file mode 100644 index 0000000000..0da69ebb56 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt @@ -0,0 +1,66 @@ +package com.tangem.domain.staking + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensureNotNull +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.walletmanager.WalletManagersFacade + +/** + * Factory class for creating instances of [StakingID] + * + * @property walletManagersFacade wallet manager facade + * +[REDACTED_AUTHOR] + */ +class StakingIdFactory( + private val walletManagersFacade: WalletManagersFacade, +) { + + /** + * Creates a [StakingID] for the given user wallet and cryptocurrency + * + * @param userWalletId the unique identifier of the user's wallet + + */ + suspend fun create(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Either { + return create(userWalletId = userWalletId, currencyId = cryptoCurrency.id, network = cryptoCurrency.network) + } + + /** + * Creates a [StakingID] for the given user wallet, currency, and network + * + * @param userWalletId the unique identifier of the user's wallet + * @param currencyId the identifier of the cryptocurrency + * @param network the network associated with the staking operation + */ + suspend fun create( + userWalletId: UserWalletId, + currencyId: CryptoCurrency.ID, + network: Network, + ): Either = either { + val integrationId = StakingIntegrationID.create(currencyId = currencyId) + + ensureNotNull(integrationId) { Error.UnsupportedCurrency } + + val address = walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network) + + ensureNotNull(address) { Error.UnableToGetAddress(integrationId = integrationId) } + + StakingID(integrationId = integrationId.value, address = address) + } + + /** Represents possible errors that can occur during staking ID creation */ + sealed interface Error { + + /** Error indicating that the currency is not supported for staking */ + data object UnsupportedCurrency : Error + + /** Error indicating that the address could not be retrieved */ + data class UnableToGetAddress(val integrationId: StakingIntegrationID) : Error + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt new file mode 100644 index 0000000000..d7b1631aeb --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt @@ -0,0 +1,143 @@ +package com.tangem.domain.staking.model + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toMigratedCoinId +import com.tangem.domain.models.currency.CryptoCurrency + +/** + * Represents a staking integration identifier. + * + * Each implementation of this interface provides details about a specific staking integration, + * including its unique value, associated blockchain, and approval requirements. + * +[REDACTED_AUTHOR] + */ +sealed interface StakingIntegrationID { + + /** Unique identifier for the staking integration */ + val value: String + + /** Blockchain associated with the staking integration */ + val blockchain: Blockchain + + /** Approval requirements for the staking integration. Defaults to no approval needed */ + val approval: StakingApproval get() = StakingApproval.Empty + + /** Represents blockchains whose native coins can be staked */ + enum class Coin : StakingIntegrationID { + Ton { + override val value: String = "ton-ton-chorus-one-pools-staking" + override val blockchain: Blockchain = Blockchain.TON + }, + Solana { + override val value: String = "solana-sol-native-multivalidator-staking" + override val blockchain: Blockchain = Blockchain.Solana + }, + Cosmos { + override val value: String = "cosmos-atom-native-staking" + override val blockchain: Blockchain = Blockchain.Cosmos + }, + Tron { + override val value: String = "tron-trx-native-staking" + override val blockchain: Blockchain = Blockchain.Tron + }, + BSC { + override val value: String = "bsc-bnb-native-staking" + override val blockchain: Blockchain = Blockchain.BSC + }, + Cardano { + override val value: String = "cardano-ada-native-staking" + override val blockchain: Blockchain = Blockchain.Cardano + }, + } + + /** + * Represents staking integrations for Ethereum-based tokens + * + * @property subBlockchain the specific Ethereum-based blockchain associated with the integration + */ + enum class EthereumToken(val subBlockchain: Blockchain) : StakingIntegrationID { + Polygon(subBlockchain = Blockchain.Polygon) { + override val value: String = "ethereum-matic-native-staking" + override val approval: StakingApproval.Needed = + StakingApproval.Needed(spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908") + }, + ; + + /** Blockchain associated with Ethereum-based staking integrations. */ + override val blockchain: Blockchain = Blockchain.Ethereum + } + + // Polkadot { + // override val value: String = "polkadot-dot-validator-staking" + // override val blockchain: Blockchain = Blockchain.Polkadot + // }, + // Avalanche { + // override val value: String = "avalanche-avax-native-staking" + // override val blockchain: Blockchain = Blockchain.Avalanche + // }, + // Cronos { + // override val value: String = "cronos-cro-native-staking" + // override val blockchain: Blockchain = Blockchain.Cronos + // }, + // Kava { + // override val value: String = "kava-kava-native-staking" + // override val blockchain: Blockchain = Blockchain.Kava + // }, + // Near { + // override val value: String = "near-near-native-staking" + // override val blockchain: Blockchain = Blockchain.Near + // }, + // Tezos { + // override val value: String = "tezos-xtz-native-staking" + // override val blockchain: Blockchain = Blockchain.Tezos + // }, + + companion object { + + /** List of all native staking integration IDs */ + val entries: List by lazy { Coin.entries + EthereumToken.entries } + + /** + * Creates a [StakingIntegrationID] for the given cryptocurrency ID + * + * @param currencyId the identifier of the cryptocurrency + * + * @return a [StakingIntegrationID] if supported, or `null` if not supported. + */ + fun create(currencyId: CryptoCurrency.ID): StakingIntegrationID? { + val blockchain = Blockchain.fromId(id = currencyId.rawNetworkId) + + val integrationId = blockchain.integrationId ?: return null + + return when (integrationId) { + is Coin -> integrationId + is EthereumToken -> { + val coinId = integrationId.subBlockchain.toMigratedCoinId() + + if (coinId == currencyId.rawCurrencyId?.value) { + integrationId + } else { + null + } + } + } + } + } +} + +/** + * Extension property to check if staking is supported for a blockchain. + * + * @return `true` if staking is supported, `false` otherwise. + */ +val Blockchain.isStakingSupported: Boolean + get() = StakingIntegrationID.entries.any { it.blockchain == this } + +/** + * Extension property to retrieve the staking integration ID for a blockchain. + * + * @return the [StakingIntegrationID] if available, or `null` if not supported. + */ +val Blockchain.integrationId: StakingIntegrationID? + get() = StakingIntegrationID.entries.firstOrNull { it.blockchain == this } \ No newline at end of file diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt new file mode 100644 index 0000000000..cd77c25e9e --- /dev/null +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt @@ -0,0 +1,167 @@ +package com.tangem.domain.staking + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.walletmanager.WalletManagersFacade +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class StakingIdFactoryTest { + + private val walletManagersFacade: WalletManagersFacade = mockk() + private val factory = StakingIdFactory(walletManagersFacade = walletManagersFacade) + + @BeforeEach + fun resetMocks() { + clearMocks(walletManagersFacade) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Create { + + private val defaultAddress = "address" + + @Test + fun `create returns UnsupportedCurrency if integrationId is null`() = runTest { + // Arrange + val userWalletId = UserWalletId(stringValue = "011") + val currency = MockCryptoCurrencyFactory().ethereum + + // Act + val actual = factory.create( + userWalletId = userWalletId, + currencyId = currency.id, + network = currency.network, + ) + + // Assert + val expected = StakingIdFactory.Error.UnsupportedCurrency + + Truth.assertThat(actual.leftOrNull()).isEqualTo(expected) + + coVerify(inverse = true) { + walletManagersFacade.getDefaultAddress(userWalletId = any(), network = any()) + } + } + + @Test + fun `create returns UnableToGetAddress if address is null`() = runTest { + // Arrange + val userWalletId = UserWalletId(stringValue = "011") + val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.TON) + + coEvery { + walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = currency.network) + } returns null + + // Act + val actual = factory.create( + userWalletId = userWalletId, + currencyId = currency.id, + network = currency.network, + ) + + // Assert + val expected = StakingIdFactory.Error.UnableToGetAddress( + integrationId = StakingIntegrationID.Coin.Ton, + ) + + Truth.assertThat(actual.leftOrNull()).isEqualTo(expected) + + coVerify(exactly = 1) { + walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = currency.network) + } + } + + @ParameterizedTest + @ProvideTestModels + fun create(model: CreateModel) = runTest { + // Arrange + val userWalletId = UserWalletId(stringValue = "011") + val network = MockCryptoCurrencyFactory().createCoin(Blockchain.TON).network + + coEvery { + walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network) + } returns defaultAddress + + // Act + val actual = factory.create(userWalletId = userWalletId, currencyId = model.currencyId, network = network) + + // Assert + Truth.assertThat(actual).isEqualTo(model.expected) + + if (model.expected.leftOrNull() != StakingIdFactory.Error.UnsupportedCurrency) { + coVerify(exactly = 1) { + walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network) + } + } + } + + private fun provideTestModels() = listOf( + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Bitcoin), + expected = StakingIdFactory.Error.UnsupportedCurrency.left(), + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.TON), + expected = createStakingId(integrationId = StakingIntegrationID.Coin.Ton), + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Solana), + expected = createStakingId(integrationId = StakingIntegrationID.Coin.Solana), + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Cosmos), + expected = createStakingId(integrationId = StakingIntegrationID.Coin.Cosmos), + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Tron), + expected = createStakingId(integrationId = StakingIntegrationID.Coin.Tron), + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.BSC), + expected = createStakingId(integrationId = StakingIntegrationID.Coin.BSC), + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Cardano), + expected = createStakingId(integrationId = StakingIntegrationID.Coin.Cardano), + ), + CreateModel( + currencyId = CryptoCurrency.ID.fromValue(value = "coin⟨ETH⟩polygon-ecosystem-token⚓"), + expected = createStakingId(integrationId = StakingIntegrationID.EthereumToken.Polygon), + ), + ) + + private fun createStakingId(integrationId: StakingIntegrationID): Either { + return StakingID(integrationId = integrationId.value, address = defaultAddress).right() + } + } + + data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: Either) + + private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID { + return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}⟩") + } +} \ No newline at end of file diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt new file mode 100644 index 0000000000..965179516f --- /dev/null +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt @@ -0,0 +1,153 @@ +package com.tangem.domain.staking + +import com.google.common.truth.Truth +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.utils.ProvideTestModels +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingIntegrationID +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class StakingIntegrationIDTest { + + @Test + fun `all IDs are unique`() { + // Act + val actual = StakingIntegrationID.entries.distinctBy { it.value } + + // Assert + val expected = StakingIntegrationID.entries.size + Truth.assertThat(actual).hasSize(expected) + } + + @Test + fun `all blockchains are unique`() { + // Act + val actual = StakingIntegrationID.entries.distinctBy(StakingIntegrationID::blockchain) + + // Assert + val expected = StakingIntegrationID.entries.size + Truth.assertThat(actual).hasSize(expected) + } + + @Test + fun `all sub blockchains are unique`() { + // Act + val actual = StakingIntegrationID.EthereumToken.entries + .distinctBy(StakingIntegrationID.EthereumToken::subBlockchain) + + // Assert + val expected = StakingIntegrationID.EthereumToken.entries.size + Truth.assertThat(actual).hasSize(expected) + } + + @Test + fun `all spenderAddress of StakingApproval are unique`() { + // Arrange + val allNeededApproval = StakingIntegrationID.entries.map(StakingIntegrationID::approval) + .filterIsInstance() + + // Act + val actual = allNeededApproval.distinctBy(StakingApproval.Needed::spenderAddress) + + // Assert + val expected = allNeededApproval.size + Truth.assertThat(actual).hasSize(expected) + } + + @Test + fun `all EthereumToken IDs are ethereum based blockchains`() { + // Act + val actual = StakingIntegrationID.EthereumToken.entries + .map(StakingIntegrationID.EthereumToken::subBlockchain) + .all { it.isEvm() } + + // Assert + Truth.assertThat(actual).isTrue() + } + + @Test + fun `all Coin IDs do not need staking approval`() { + // Act + val actual = StakingIntegrationID.Coin.entries + .map(StakingIntegrationID.Coin::approval) + .none { it is StakingApproval.Needed } + + // Assert + Truth.assertThat(actual).isTrue() + } + + @Test + fun `all EthereumToken IDs need staking approval`() { + // Act + val actual = StakingIntegrationID.EthereumToken.entries + .map(StakingIntegrationID.EthereumToken::approval) + .all { it is StakingApproval.Needed } + + // Assert + Truth.assertThat(actual).isTrue() + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Create { + + @ParameterizedTest + @ProvideTestModels + fun create(model: CreateModel) { + // Act + val actual = StakingIntegrationID.create(currencyId = model.currencyId) + + // Assert + Truth.assertThat(actual).isEqualTo(model.expected) + } + + private fun provideTestModels() = listOf( + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Bitcoin), + expected = null, + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.TON), + expected = StakingIntegrationID.Coin.Ton, + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Solana), + expected = StakingIntegrationID.Coin.Solana, + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Cosmos), + expected = StakingIntegrationID.Coin.Cosmos, + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Tron), + expected = StakingIntegrationID.Coin.Tron, + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.BSC), + expected = StakingIntegrationID.Coin.BSC, + ), + CreateModel( + currencyId = createCurrencyId(blockchain = Blockchain.Cardano), + expected = StakingIntegrationID.Coin.Cardano, + ), + CreateModel( + currencyId = CryptoCurrency.ID.fromValue(value = "coin⟨ETH⟩polygon-ecosystem-token⚓"), + expected = StakingIntegrationID.EthereumToken.Polygon, + ), + ) + } + + data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: StakingIntegrationID?) + + private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID { + return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}⟩") + } +} \ No newline at end of file