Updated on 2026-08-14
This commit is contained in:
parent
ae79b95521
commit
311e0e8e3f
5 changed files with 542 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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}⟩")
|
||||
}
|
||||
}
|
||||
|
|
@ -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}⟩")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue