Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-25 13:07:39 +04:00
parent ae79b95521
commit 311e0e8e3f
5 changed files with 542 additions and 0 deletions

View file

@ -9,6 +9,10 @@ android {
namespace = "com.tangem.domain.staking"
}
tasks.withType<Test>().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)
}

View file

@ -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<Error, StakingID> {
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<Error, StakingID> = 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
}
}

View file

@ -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<StakingIntegrationID> 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 }

View file

@ -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<Nothing, StakingID> {
return StakingID(integrationId = integrationId.value, address = defaultAddress).right()
}
}
data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: Either<StakingIdFactory.Error, StakingID>)
private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID {
return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}")
}
}

View file

@ -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<StakingApproval.Needed>()
// 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}")
}
}