From dd95bf238aab1780f20b9614a897ae8496203544 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 2 Jul 2026 10:37:01 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/ManageTokensDomainModule.kt | 8 +++ .../DefaultCustomTokensRepository.kt | 16 +++++ .../CheckDerivationPathSupportedUseCase.kt | 37 ++++++++++ .../repository/CustomTokensRepository.kt | 6 ++ .../model/CustomTokenFormModel.kt | 23 +++++++ .../EllipticCurveDerivationSupport.kt | 31 +++++++++ .../EllipticCurveDerivationSupportTest.kt | 68 +++++++++++++++++++ 7 files changed, 189 insertions(+) create mode 100644 domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckDerivationPathSupportedUseCase.kt create mode 100644 libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/EllipticCurveDerivationSupport.kt create mode 100644 libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/EllipticCurveDerivationSupportTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt index 2ab0eca738..cf117494a2 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt @@ -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 { diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt index 9d48157eef..669283b61e 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt @@ -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 = withContext(dispatchers.io) { when (val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)) { is UserWallet.Hot -> { diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckDerivationPathSupportedUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckDerivationPathSupportedUseCase.kt new file mode 100644 index 0000000000..fcd0ef9951 --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/CheckDerivationPathSupportedUseCase.kt @@ -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 = runSuspendCatching { + customTokensRepository.isDerivationPathSupported( + userWalletId = userWalletId, + networkId = networkId, + derivationPath = derivationPath, + ) + }.fold( + onSuccess = { it.right() }, + onFailure = { it.left() }, + ) +} \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt index 3d27222db6..dc64f8e579 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/repository/CustomTokensRepository.kt @@ -43,5 +43,11 @@ interface CustomTokensRepository { suspend fun getSupportedNetworks(userWalletId: UserWalletId): List + suspend fun isDerivationPathSupported( + userWalletId: UserWalletId, + networkId: Network.ID, + derivationPath: Network.DerivationPath, + ): Boolean + fun createDerivationPath(rawPath: String): Network.DerivationPath } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt index 16c044adb6..ee7157a386 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt @@ -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 diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/EllipticCurveDerivationSupport.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/EllipticCurveDerivationSupport.kt new file mode 100644 index 0000000000..d47c98831a --- /dev/null +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/derivation/EllipticCurveDerivationSupport.kt @@ -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 + } +} \ No newline at end of file diff --git a/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/EllipticCurveDerivationSupportTest.kt b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/EllipticCurveDerivationSupportTest.kt new file mode 100644 index 0000000000..7e589b87f0 --- /dev/null +++ b/libs/crypto/src/test/kotlin/com/tangem/lib/crypto/derivation/EllipticCurveDerivationSupportTest.kt @@ -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, + ) +} \ No newline at end of file