Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-02 10:37:01 +05:00
parent adb8c50ae2
commit dd95bf238a
7 changed files with 189 additions and 0 deletions

View file

@ -56,6 +56,14 @@ internal object ManageTokensDomainModule {
return ValidateDerivationPathUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckDerivationPathSupportedUseCase(
customTokensRepository: CustomTokensRepository,
): CheckDerivationPathSupportedUseCase {
return CheckDerivationPathSupportedUseCase(customTokensRepository)
}
@Provides
@Singleton
fun provideCheckCurrencyUnsupportedUseCase(repository: ManageTokensRepository): CheckCurrencyUnsupportedUseCase {

View file

@ -24,6 +24,8 @@ 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.models.wallet.UserWalletId
import com.tangem.domain.wallets.config.curvesConfig
import com.tangem.lib.crypto.derivation.supportsDerivationPath
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
@ -217,6 +219,20 @@ internal class DefaultCustomTokensRepository(
}
}
override suspend fun isDerivationPathSupported(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): Boolean = withContext(dispatchers.io) {
val rawPath = derivationPath.value ?: return@withContext true
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
val blockchain = networkId.toBlockchain()
val curve = userWallet.curvesConfig.primaryCurve(blockchain) ?: return@withContext false
val path = runCatching { DerivationPath(rawPath) }.getOrNull() ?: return@withContext false
curve.supportsDerivationPath(path)
}
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> = withContext(dispatchers.io) {
when (val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)) {
is UserWallet.Hot -> {

View file

@ -0,0 +1,37 @@
package com.tangem.domain.managetokens
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.runSuspendCatching
/**
* Checks whether the elliptic curve used by [networkId] for the [userWalletId] wallet supports the selected
* [derivationPath]. Used before adding a custom token to prevent adding a token with a derivation path that the
* network's curve cannot derive (e.g. an Algorand token with an EVM derivation path).
*
* Returns [Either.Right] with `true` when the derivation is supported, `false` otherwise. [Either.Left] is returned
* when the check itself fails unexpectedly.
*/
class CheckDerivationPathSupportedUseCase(
private val customTokensRepository: CustomTokensRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): Either<Throwable, Boolean> = runSuspendCatching {
customTokensRepository.isDerivationPathSupported(
userWalletId = userWalletId,
networkId = networkId,
derivationPath = derivationPath,
)
}.fold(
onSuccess = { it.right() },
onFailure = { it.left() },
)
}

View file

@ -43,5 +43,11 @@ interface CustomTokensRepository {
suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network>
suspend fun isDerivationPathSupported(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): Boolean
fun createDerivationPath(rawPath: String): Network.DerivationPath
}

View file

@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.managetokens.CheckDerivationPathSupportedUseCase
import com.tangem.domain.managetokens.CreateCryptoCurrencyUseCase
import com.tangem.domain.managetokens.FindTokenUseCase
import com.tangem.domain.managetokens.ValidateTokenFormUseCase
@ -51,6 +52,7 @@ internal class CustomTokenFormModel @Inject constructor(
private val createCryptoCurrencyUseCase: CreateCryptoCurrencyUseCase,
private val findTokenUseCase: FindTokenUseCase,
private val validateTokenFormUseCase: ValidateTokenFormUseCase,
private val checkDerivationPathSupportedUseCase: CheckDerivationPathSupportedUseCase,
paramsContainer: ParamsContainer,
customTokenFormUseCasesFacadeFactory: CustomTokenFormUseCasesFacade.Factory,
) : Model() {
@ -238,6 +240,14 @@ internal class CustomTokenFormModel @Inject constructor(
}
}
private fun showDerivationPathNotSupportedDialog() {
val dialog = DialogMessage(
message = resourceReference(R.string.custom_token_invalid_derivation_path),
)
messageSender.send(dialog)
}
private fun showErrorDialog(throwable: Throwable) {
TangemLogger.e("Error", throwable)
val message = when (throwable) {
@ -355,6 +365,19 @@ internal class CustomTokenFormModel @Inject constructor(
return@resource
}
val isDerivationSupported = checkDerivationPathSupportedUseCase(
userWalletId = params.mode.userWalletId,
networkId = currency.network.id,
derivationPath = currency.network.derivationPath,
).getOrElse { throwable ->
showErrorDialog(throwable)
return@resource
}
if (!isDerivationSupported) {
showDerivationPathNotSupportedDialog()
return@resource
}
useCasesFacade.addCryptoCurrenciesUseCase(currency).getOrElse { throwable ->
showErrorDialog(throwable)
return@resource

View file

@ -0,0 +1,31 @@
package com.tangem.lib.crypto.derivation
import com.tangem.common.card.EllipticCurve
import com.tangem.crypto.hdWallet.DerivationPath
/**
* Checks whether this [EllipticCurve] is able to derive the given [path].
*
* `ed25519` and `ed25519_slip0010` support hardened derivation only (SLIP-0010), so any path that contains a
* non-hardened node cannot produce a key/address for them. This is the root cause of the "custom token added without
* an address" bug: e.g. an Algorand (ed25519) token with an EVM derivation path like `m/44'/60'/0'/0/0`.
*
* `secp256k1`, `secp256r1` and `bip0340` support both hardened and non-hardened derivation, so any path is fine.
*
* BLS curves do not support derivation at all.
*/
fun EllipticCurve.supportsDerivationPath(path: DerivationPath): Boolean {
return when (this) {
EllipticCurve.Ed25519,
EllipticCurve.Ed25519Slip0010,
-> path.nodes.all { it.isHardened }
EllipticCurve.Secp256k1,
EllipticCurve.Secp256r1,
EllipticCurve.Bip0340,
-> true
EllipticCurve.Bls12381G2,
EllipticCurve.Bls12381G2Aug,
EllipticCurve.Bls12381G2Pop,
-> false
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.lib.crypto.derivation
import com.google.common.truth.Truth
import com.tangem.common.card.EllipticCurve
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class EllipticCurveDerivationSupportTest {
@ParameterizedTest
@ProvideTestModels
fun supportsDerivationPath(model: TestModel) {
// Arrange
val path = DerivationPath(rawPath = model.derivationPath)
// Act
val actual = model.curve.supportsDerivationPath(path)
// Assert
Truth.assertThat(actual).isEqualTo(model.expected)
}
private fun provideTestModels() = provideEd25519Models() +
provideSecpModels() +
provideBlsModels()
private fun provideEd25519Models() = listOf(
// region ed25519-family require a fully-hardened path
// Algorand default path — fully hardened
TestModel(curve = EllipticCurve.Ed25519, derivationPath = "m/44'/283'/0'/0'/0'", expected = true),
TestModel(curve = EllipticCurve.Ed25519Slip0010, derivationPath = "m/44'/283'/0'/0'/0'", expected = true),
// The reported bug: Algorand (ed25519) + ApeChain/EVM path with non-hardened tail
TestModel(curve = EllipticCurve.Ed25519, derivationPath = "m/44'/60'/0'/0/0", expected = false),
TestModel(curve = EllipticCurve.Ed25519Slip0010, derivationPath = "m/44'/60'/0'/0/0", expected = false),
// Solana default path — fully hardened
TestModel(curve = EllipticCurve.Ed25519Slip0010, derivationPath = "m/44'/501'/0'/0'", expected = true),
// A single non-hardened node is enough to make it unsupported
TestModel(curve = EllipticCurve.Ed25519, derivationPath = "m/44'/283'/0'/0'/0", expected = false),
// endregion
)
private fun provideSecpModels() = listOf(
// region secp256k1 / secp256r1 / bip0340 accept any path
TestModel(curve = EllipticCurve.Secp256k1, derivationPath = "m/44'/60'/0'/0/0", expected = true),
TestModel(curve = EllipticCurve.Secp256k1, derivationPath = "m/44'/0'/0'/0/0", expected = true),
TestModel(curve = EllipticCurve.Secp256k1, derivationPath = "m/44'/283'/0'/0'/0'", expected = true),
TestModel(curve = EllipticCurve.Secp256r1, derivationPath = "m/44'/60'/0'/0/0", expected = true),
TestModel(curve = EllipticCurve.Bip0340, derivationPath = "m/44'/60'/0'/0/0", expected = true),
// endregion
)
private fun provideBlsModels() = listOf(
// region BLS curves do not support derivation at all
TestModel(curve = EllipticCurve.Bls12381G2, derivationPath = "m/44'/60'/0'/0/0", expected = false),
TestModel(curve = EllipticCurve.Bls12381G2Aug, derivationPath = "m/44'/60'/0'/0'/0'", expected = false),
TestModel(curve = EllipticCurve.Bls12381G2Pop, derivationPath = "m/44'/60'/0'/0'/0'", expected = false),
// endregion
)
data class TestModel(
val curve: EllipticCurve,
val derivationPath: String,
val expected: Boolean,
)
}