Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-31 16:03:56 +05:00
parent 9a6b9f4b82
commit 149a638c0d
16 changed files with 542 additions and 139 deletions

View file

@ -29,6 +29,7 @@ dependencies {
/** Domain */
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
implementation(projects.domain.legacy)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets.models)

View file

@ -0,0 +1,73 @@
package com.tangem.data.transaction
import com.tangem.blockchain.common.Approver
import com.tangem.blockchain.common.Token
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.AllowanceRepository
import com.tangem.domain.transaction.models.AllowanceInfo
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.math.BigDecimal
internal class DefaultAllowanceRepository(
private val walletManagersFacade: WalletManagersFacade,
private val dispatchers: CoroutineDispatcherProvider,
) : AllowanceRepository {
override suspend fun getAllowanceInfo(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
spenderAddress: String,
requiredAmount: BigDecimal,
): AllowanceInfo {
if (cryptoCurrency !is CryptoCurrency.Token) {
error("CryptoCurrency must be of type Token")
}
val allowance = getAllowance(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
spenderAddress = spenderAddress,
)
return when {
allowance >= requiredAmount -> AllowanceInfo.Enough(allowance)
allowance > BigDecimal.ZERO && allowance < requiredAmount &&
BlockchainUtils.isTetherInEthereum(
blockchainId = cryptoCurrency.network.rawId,
contractAddress = cryptoCurrency.contractAddress,
) -> AllowanceInfo.ResetNeeded(allowance, requiredAmount)
else -> AllowanceInfo.NotEnough(allowance, requiredAmount)
}
}
override suspend fun getAllowance(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
spenderAddress: String,
): BigDecimal = withContext(dispatchers.io) {
if (cryptoCurrency !is CryptoCurrency.Token) {
error("CryptoCurrency must be of type Token")
}
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, cryptoCurrency.network)
val blockchain = cryptoCurrency.network.toBlockchain()
val allowanceResult = (walletManager as? Approver)?.getAllowance(
spenderAddress,
Token(
symbol = blockchain.currency,
contractAddress = cryptoCurrency.contractAddress,
decimals = cryptoCurrency.decimals,
),
) ?: error("Cannot cast to Approver")
allowanceResult.fold(
onSuccess = { it },
onFailure = { error(it) },
)
}
}

View file

@ -25,7 +25,6 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.OperationType
import com.tangem.datasource.api.tangemTech.models.TransactionEventBody
import com.tangem.datasource.local.walletmanager.WalletManagersStore
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.transaction.TransactionRepository
@ -332,28 +331,6 @@ internal class DefaultTransactionRepository(
}
}
override suspend fun getAllowance(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency.Token,
spenderAddress: String,
): BigDecimal {
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, cryptoCurrency.network)
val blockchain = cryptoCurrency.network.toBlockchain()
val allowanceResult = (walletManager as? Approver)?.getAllowance(
spenderAddress,
Token(
symbol = blockchain.currency,
contractAddress = cryptoCurrency.contractAddress,
decimals = cryptoCurrency.decimals,
),
) ?: error("Cannot cast to Approver")
return allowanceResult.fold(
onSuccess = { it },
onFailure = { error(it) },
)
}
@Suppress("CyclomaticComplexMethod")
private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? {
val blockchain = Blockchain.fromId(networkId)

View file

@ -1,22 +1,14 @@
package com.tangem.data.transaction.di
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.transaction.DefaultFeeRepository
import com.tangem.data.transaction.DefaultGaslessTransactionRepository
import com.tangem.data.transaction.DefaultMemoValidatorFacade
import com.tangem.data.transaction.DefaultTransactionRepository
import com.tangem.data.transaction.DefaultWalletAddressServiceRepository
import com.tangem.data.transaction.*
import com.tangem.data.transaction.error.DefaultFeeErrorResolver
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.datasource.api.gasless.GaslessTxServiceApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.MemoValidatorFacade
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.transaction.WalletAddressServiceRepository
import com.tangem.domain.transaction.*
import com.tangem.domain.transaction.error.FeeErrorResolver
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -100,4 +92,16 @@ internal object TransactionDataModule {
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
)
}
@Provides
@Singleton
fun provideAllowanceRepository(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): AllowanceRepository {
return DefaultAllowanceRepository(
walletManagersFacade = walletManagersFacade,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,305 @@
package com.tangem.data.transaction
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Approver
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.WalletManager
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.transaction.models.AllowanceInfo
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
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.assertThrows
import java.math.BigDecimal
class DefaultAllowanceRepositoryTest {
private val userWalletId = UserWalletId(stringValue = "1234567890ABCDEF")
private val spenderAddress = "0xSpender"
private val approverWalletManager: WalletManager =
mockk<WalletManager>(moreInterfaces = arrayOf(Approver::class))
private val walletManagersFacade: WalletManagersFacade = mockk {
coEvery { getOrCreateWalletManager(userWalletId, any<Network>()) } returns approverWalletManager
}
private val dispatchers = TestingCoroutineDispatcherProvider()
private lateinit var repository: DefaultAllowanceRepository
@BeforeEach
fun setup() {
repository = DefaultAllowanceRepository(
walletManagersFacade = walletManagersFacade,
dispatchers = dispatchers,
)
}
// region getAllowance
@Nested
inner class GetAllowanceTests {
@Test
fun `throws when cryptoCurrency is Coin`() = runTest {
val coin = buildCoin()
assertThrows<IllegalStateException> {
repository.getAllowance(userWalletId, coin, spenderAddress)
}
}
@Test
fun `throws when walletManager is null`() = runTest {
val token = buildToken()
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, token.network)
} returns null
assertThrows<IllegalStateException> {
repository.getAllowance(userWalletId, token, spenderAddress)
}
}
@Test
fun `throws when walletManager is not Approver`() = runTest {
val token = buildToken()
val nonApproverWalletManager: WalletManager = mockk()
coEvery {
walletManagersFacade.getOrCreateWalletManager(userWalletId, token.network)
} returns nonApproverWalletManager
assertThrows<IllegalStateException> {
repository.getAllowance(userWalletId, token, spenderAddress)
}
}
@Test
fun `returns allowance on success`() = runTest {
val token = buildToken()
val expected = BigDecimal("100")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any<Token>())
} returns Result.success(expected)
val result = repository.getAllowance(userWalletId, token, spenderAddress)
assertThat(result).isEqualTo(expected)
}
@Test
fun `throws when approver returns failure`() = runTest {
val token = buildToken()
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any<Token>())
} returns Result.failure(RuntimeException("rpc error"))
assertThrows<IllegalStateException> {
repository.getAllowance(userWalletId, token, spenderAddress)
}
}
}
// endregion
// region getAllowanceInfo
@Nested
inner class GetAllowanceInfoTests {
@Test
fun `throws when cryptoCurrency is Coin`() = runTest {
val coin = buildCoin()
assertThrows<IllegalStateException> {
repository.getAllowanceInfo(userWalletId, coin, spenderAddress, BigDecimal.ONE)
}
}
@Test
fun `returns Enough when allowance equals required amount`() = runTest {
val token = buildToken(rawNetworkId = "polygon", rawCurrencyId = "usd-coin")
val amount = BigDecimal("100")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
} returns Result.success(amount)
val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, amount)
assertThat(result).isInstanceOf(AllowanceInfo.Enough::class.java)
assertThat((result as AllowanceInfo.Enough).allowance).isEqualTo(amount)
}
@Test
fun `returns Enough when allowance exceeds required amount`() = runTest {
val token = buildToken(rawNetworkId = "polygon", rawCurrencyId = "usd-coin")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
} returns Result.success(BigDecimal("200"))
val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100"))
assertThat(result).isInstanceOf(AllowanceInfo.Enough::class.java)
assertThat((result as AllowanceInfo.Enough).allowance).isEqualTo(BigDecimal("200"))
}
@Test
fun `returns NotEnough when allowance is zero`() = runTest {
val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "tether")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
} returns Result.success(BigDecimal.ZERO)
val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("50"))
assertThat(result).isInstanceOf(AllowanceInfo.NotEnough::class.java)
result as AllowanceInfo.NotEnough
assertThat(result.allowance).isEqualTo(BigDecimal.ZERO)
assertThat(result.requiredAmount).isEqualTo(BigDecimal("50"))
}
@Test
fun `returns NotEnough when partial allowance for non-tether token`() = runTest {
val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "usd-coin")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
} returns Result.success(BigDecimal("30"))
val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100"))
assertThat(result).isInstanceOf(AllowanceInfo.NotEnough::class.java)
result as AllowanceInfo.NotEnough
assertThat(result.allowance).isEqualTo(BigDecimal("30"))
assertThat(result.requiredAmount).isEqualTo(BigDecimal("100"))
}
@Test
fun `returns NotEnough when partial allowance for tether on non-ethereum network`() = runTest {
val token = buildToken(rawNetworkId = "polygon", rawCurrencyId = "tether")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
} returns Result.success(BigDecimal("30"))
val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100"))
assertThat(result).isInstanceOf(AllowanceInfo.NotEnough::class.java)
}
@Test
fun `returns ResetNeeded when partial allowance for tether on ethereum`() = runTest {
val token = buildToken(rawNetworkId = "ETH", rawCurrencyId = "tether")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
} returns Result.success(BigDecimal("30"))
val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100"))
assertThat(result).isInstanceOf(AllowanceInfo.ResetNeeded::class.java)
result as AllowanceInfo.ResetNeeded
assertThat(result.allowance).isEqualTo(BigDecimal("30"))
assertThat(result.requiredAmount).isEqualTo(BigDecimal("100"))
}
@Test
fun `returns ResetNeeded when partial allowance for tether on ethereum testnet`() = runTest {
val token = buildToken(rawNetworkId = "ETH/test", rawCurrencyId = "tether")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
} returns Result.success(BigDecimal("10"))
val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("50"))
assertThat(result).isInstanceOf(AllowanceInfo.ResetNeeded::class.java)
}
@Test
fun `returns Enough for tether on ethereum when allowance is sufficient`() = runTest {
val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "tether")
coEvery {
(approverWalletManager as Approver).getAllowance(spenderAddress, any())
} returns Result.success(BigDecimal("100"))
val result = repository.getAllowanceInfo(userWalletId, token, spenderAddress, BigDecimal("100"))
assertThat(result).isInstanceOf(AllowanceInfo.Enough::class.java)
}
}
// endregion
// region Helpers
private fun buildNetwork(rawNetworkId: String): Network {
val derivationPath = Network.DerivationPath.None
return Network(
id = Network.ID(Network.RawID(rawNetworkId), derivationPath),
backendId = rawNetworkId,
name = rawNetworkId.replaceFirstChar { it.uppercase() },
currencySymbol = "ETH",
derivationPath = derivationPath,
isTestnet = rawNetworkId.contains("test"),
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = false,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
private fun buildToken(
rawNetworkId: String = "ETH",
rawCurrencyId: String = "tether",
contractAddress: String = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
): CryptoCurrency.Token {
val network = buildNetwork(rawNetworkId)
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawCurrencyId),
),
network = network,
name = "Token",
symbol = "TKN",
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = contractAddress,
)
}
private fun buildCoin(rawNetworkId: String = "ethereum"): CryptoCurrency.Coin {
val network = buildNetwork(rawNetworkId)
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
),
network = network,
name = "Ethereum",
symbol = "ETH",
decimals = 18,
iconUrl = null,
isCustom = false,
)
}
// endregion
}