Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-20 12:34:01 +03:00
parent 53a84a6aa8
commit d9f3a8e456
9 changed files with 376 additions and 45 deletions

View file

@ -1,11 +1,11 @@
package com.tangem.data.dynamicaddresses
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
@ -97,34 +97,30 @@ internal class DefaultDynamicAddressesRepository(
.flatMap { it.tokens.orEmpty() }
.any { token ->
val tokenDerivationPath = token.derivationPath ?: return@any false
token.networkId == network.rawId &&
token.networkId == network.id.rawId.value &&
tokenDerivationPath != baseDerivationPath &&
hasNonZeroChangeOrIndex(tokenDerivationPath, baseDerivationPath)
DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex(
customPath = tokenDerivationPath,
basePath = baseDerivationPath,
)
}
}
}
/**
* Checks if the token's derivation path has the same first 3 nodes (purpose/coin/account)
* as the base path but different change/index nodes (not both 0).
*/
private fun hasNonZeroChangeOrIndex(tokenPath: String, basePath: String): Boolean {
val tokenNodes = runCatching { DerivationPath(tokenPath).nodes }.getOrNull() ?: return false
val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false
if (tokenNodes.size < DERIVATION_NODE_COUNT || baseNodes.size < DERIVATION_NODE_COUNT) return false
// First 3 nodes must match (purpose/coin/account) by value, ignoring hardening
val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i ->
tokenNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false)
}
if (!isSameAccount) return false
// Check if change or index ≠ 0
val change = tokenNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)
val index = tokenNodes[INDEX_NODE_INDEX].getIndex(includeHardened = false)
return change != 0L || index != 0L
override fun isDynamicAddressesEnabledForNetwork(
userWalletId: UserWalletId,
networkId: Network.ID,
): Flow<Boolean> {
return walletAccountsFetcher.get(userWalletId)
.map { response ->
response.accounts
.flatMap { it.tokens.orEmpty() }
.any { token ->
token.matchesNetwork(networkId) &&
token.dynamicAddressesEnabled == true
}
}
.flowOn(dispatchers.io)
}
private suspend fun updateTokenDynamicAddressesFlag(
@ -137,7 +133,7 @@ internal class DefaultDynamicAddressesRepository(
accounts = response.accounts.map { account ->
account.copy(
tokens = account.tokens?.map { token ->
if (token.matchesNetwork(network)) {
if (token.matchesNetwork(network.id)) {
token.copy(dynamicAddressesEnabled = enabled)
} else {
token
@ -157,19 +153,12 @@ internal class DefaultDynamicAddressesRepository(
private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? {
return accounts
.flatMap { it.tokens.orEmpty() }
.find { it.matchesNetwork(network) }
.find { it.matchesNetwork(network.id) }
}
private fun UserTokensResponse.Token.matchesNetwork(network: Network): Boolean {
return networkId == network.rawId &&
derivationPath == network.derivationPath.value &&
private fun UserTokensResponse.Token.matchesNetwork(networkId: Network.ID): Boolean {
return this.networkId == networkId.rawId.value &&
derivationPath == networkId.derivationPath.value &&
contractAddress == null
}
private companion object {
const val DERIVATION_NODE_COUNT = 5
const val ACCOUNT_NODE_COUNT = 3
const val CHANGE_NODE_INDEX = 3
const val INDEX_NODE_INDEX = 4
}
}

View file

@ -8,6 +8,10 @@ android {
namespace = "com.tangem.domain.dynamicaddresses"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
api(projects.domain.core)
api(projects.domain.dynamicAddresses.models)
@ -21,4 +25,8 @@ dependencies {
exclude(module = "joda-time")
}
implementation(tangemDeps.card.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -0,0 +1,52 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.crypto.hdWallet.DerivationPath
/**
* Shared utility for checking if a derivation path conflicts with Dynamic Addresses.
*
* A path conflicts when it belongs to the same BIP44 account as the base path
* (first 3 nodes: purpose / coin_type / account match) but has non-zero
* change (node 3) or address_index (node 4).
*/
object DynamicAddressesDerivationChecker {
private const val BIP44_NODE_COUNT = 5
private const val ACCOUNT_NODE_COUNT = 3
private const val CHANGE_NODE_INDEX = 3
private const val ADDRESS_INDEX_NODE_INDEX = 4
/**
* @return `true` if [path] has zero change (node 3) and zero address_index (node 4).
*/
fun isBaseDerivation(path: String): Boolean {
val nodes = runCatching { DerivationPath(path).nodes }.getOrNull() ?: return false
if (nodes.size < BIP44_NODE_COUNT) return false
val change = nodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)
val index = nodes[ADDRESS_INDEX_NODE_INDEX].getIndex(includeHardened = false)
return change == 0L && index == 0L
}
/**
* @return `true` if [customPath] shares the same account as [basePath] but has
* non-zero change or address_index nodes.
*/
fun hasSameAccountWithNonZeroChangeOrIndex(customPath: String, basePath: String): Boolean {
val customNodes = runCatching { DerivationPath(customPath).nodes }.getOrNull() ?: return false
val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false
if (customNodes.size < BIP44_NODE_COUNT || baseNodes.size < BIP44_NODE_COUNT) return false
val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i ->
customNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false)
}
if (!isSameAccount) return false
val change = customNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)
val index = customNodes[ADDRESS_INDEX_NODE_INDEX].getIndex(includeHardened = false)
return change != 0L || index != 0L
}
}

View file

@ -22,4 +22,7 @@ interface DynamicAddressesRepository {
/** Returns true if there are custom tokens with change/index ≠ 0 that conflict with dynamic addresses */
suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean
/** Lightweight check: is the DA flag enabled for the native coin of the given network (no xpub availability check) */
fun isDynamicAddressesEnabledForNetwork(userWalletId: UserWalletId, networkId: Network.ID): Flow<Boolean>
}

View file

@ -0,0 +1,203 @@
package com.tangem.domain.dynamicaddresses
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DynamicAddressesDerivationCheckerTest {
// region Conflicting: same account, non-zero change or index
@Test
fun `same account, non-zero address index`() {
val result = check(custom = "m/44'/5'/0'/0/1", base = "m/44'/5'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `same account, non-zero change`() {
val result = check(custom = "m/44'/5'/0'/1/0", base = "m/44'/5'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `same account, both change and index non-zero`() {
val result = check(custom = "m/44'/5'/0'/1/5", base = "m/44'/5'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `same account, large address index`() {
val result = check(custom = "m/44'/5'/0'/0/8", base = "m/44'/5'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `bitcoin BIP84, non-zero index`() {
val result = check(custom = "m/84'/0'/0'/0/1", base = "m/84'/0'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `litecoin BIP84, non-zero change`() {
val result = check(custom = "m/84'/2'/0'/1/0", base = "m/84'/2'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `dogecoin, non-zero index`() {
val result = check(custom = "m/44'/3'/0'/0/8", base = "m/44'/3'/0'/0/0")
assertThat(result).isTrue()
}
@Test
fun `bitcoin cash, non-zero index`() {
val result = check(custom = "m/44'/145'/0'/0/3", base = "m/44'/145'/0'/0/0")
assertThat(result).isTrue()
}
// endregion
// region Not conflicting: different account
@Test
fun `different account index, non-zero address index`() {
val result = check(custom = "m/44'/5'/1'/0/1", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `different account index, zero change and index`() {
val result = check(custom = "m/44'/5'/1'/0/0", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `different coin type`() {
val result = check(custom = "m/44'/0'/0'/0/1", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `different purpose`() {
val result = check(custom = "m/84'/5'/0'/0/1", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `BIP44 custom against BIP84 base`() {
val result = check(custom = "m/44'/0'/0'/0/1", base = "m/84'/0'/0'/0/0")
assertThat(result).isFalse()
}
// endregion
// region Not conflicting: same account, zero change and index
@Test
fun `identical paths`() {
val result = check(custom = "m/44'/5'/0'/0/0", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `identical bitcoin BIP84 paths`() {
val result = check(custom = "m/84'/0'/0'/0/0", base = "m/84'/0'/0'/0/0")
assertThat(result).isFalse()
}
// endregion
// region Edge cases: invalid or incomplete paths
@Test
fun `invalid custom path`() {
val result = check(custom = "invalid", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `invalid base path`() {
val result = check(custom = "m/44'/5'/0'/0/1", base = "not_a_path")
assertThat(result).isFalse()
}
@Test
fun `empty custom path`() {
val result = check(custom = "", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `empty base path`() {
val result = check(custom = "m/44'/5'/0'/0/1", base = "")
assertThat(result).isFalse()
}
@Test
fun `both paths invalid`() {
val result = check(custom = "abc", base = "xyz")
assertThat(result).isFalse()
}
@Test
fun `custom path with fewer than 5 nodes`() {
val result = check(custom = "m/44'/5'/0'", base = "m/44'/5'/0'/0/0")
assertThat(result).isFalse()
}
@Test
fun `base path with fewer than 5 nodes`() {
val result = check(custom = "m/44'/5'/0'/0/1", base = "m/44'/5'")
assertThat(result).isFalse()
}
// endregion
// region isBaseDerivation
@Test
fun `isBaseDerivation - standard BIP44 base path`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/0/0")).isTrue()
}
@Test
fun `isBaseDerivation - BIP84 base path`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/84'/0'/0'/0/0")).isTrue()
}
@Test
fun `isBaseDerivation - non-zero index`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/0/1")).isFalse()
}
@Test
fun `isBaseDerivation - non-zero change`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/1/0")).isFalse()
}
@Test
fun `isBaseDerivation - non-zero account with zero change and index`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/2'/0/0")).isTrue()
}
@Test
fun `isBaseDerivation - invalid path`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("invalid")).isFalse()
}
@Test
fun `isBaseDerivation - too few nodes`() {
assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'")).isFalse()
}
// endregion
private fun check(custom: String, base: String): Boolean {
return DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex(
customPath = custom,
basePath = base,
)
}
}

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(projects.domain.swap.models)
implementation(projects.domain.notifications)
implementation(projects.domain.dynamicAddresses)
// region Project - Libs
implementation(projects.libs.blockchainSdk)

View file

@ -7,8 +7,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import arrow.core.getOrElse
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.managetokens.ValidateDerivationPathUseCase
import com.tangem.features.managetokens.utils.CardanoDerivationPathValidator
import com.tangem.domain.managetokens.model.exceptoin.DerivationPathValidationException
import com.tangem.domain.models.network.Network
import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent
@ -16,9 +17,12 @@ import com.tangem.features.managetokens.entity.customtoken.CustomDerivationInput
import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath
import com.tangem.features.managetokens.impl.R
import com.tangem.features.managetokens.ui.dialog.CustomDerivationInputDialog
import com.tangem.features.managetokens.utils.CardanoDerivationPathValidator
import com.tangem.features.managetokens.utils.DynamicAddressesDerivationValidator
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
@ -26,9 +30,15 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr
@Assisted context: AppComponentContext,
@Assisted private val params: CustomTokenDerivationInputComponent.Params,
private val validateDerivationPathUseCase: ValidateDerivationPathUseCase,
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
) : CustomTokenDerivationInputComponent, AppComponentContext by context {
private val cardanoDerivationPathValidator = CardanoDerivationPathValidator()
private val dynamicAddressesDerivationValidator = DynamicAddressesDerivationValidator(
dynamicAddressesRepository = dynamicAddressesRepository,
dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles,
)
private val state: MutableStateFlow<CustomDerivationInputUM> = MutableStateFlow(
value = getInitialState(),
@ -52,13 +62,15 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr
)
}
@OptIn(FlowPreview::class)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private fun observeValueUpdates() {
state
.map { it.value.text }
.distinctUntilChanged()
.sample(periodMillis = 1_000)
.onEach(::validateValue)
.flatMapLatest { value ->
flow { emit(validateValue(value)) }
}
.launchIn(componentScope)
}
@ -70,7 +82,7 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr
onConfirm = ::confirm,
)
private fun validateValue(value: String) {
private suspend fun validateValue(value: String) {
validateDerivationPathUseCase(value).getOrElse { e ->
updateWithValidationError(e)
return
@ -90,6 +102,21 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr
return
}
val isInvalidForDA = dynamicAddressesDerivationValidator.isInvalidForDynamicAddresses(
userWalletId = params.mode.userWalletId,
networkId = params.selectedNetwork.id,
path = value,
)
if (isInvalidForDA) {
state.update { state ->
state.copy(
error = resourceReference(R.string.dynamic_addresses_custom_token_error_on_addition),
isConfirmEnabled = false,
)
}
return
}
state.update { state ->
state.copy(
error = null,

View file

@ -0,0 +1,51 @@
package com.tangem.features.managetokens.utils
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.firstOrNull
/**
* Validates that custom derivation paths don't conflict with Dynamic Addresses.
*
* When DA is enabled for an account, custom derivation paths with non-zero
* change (node 3) or address_index (node 4) are forbidden, because DA
* manages those nodes automatically.
*/
internal class DynamicAddressesDerivationValidator(
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
) {
/**
* @return true if the path is invalid (DA is enabled for the same account and change/index 0)
*/
suspend fun isInvalidForDynamicAddresses(
userWalletId: UserWalletId,
networkId: Network.ID,
path: String?,
): Boolean {
if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false
if (path == null) return false
if (!isSupportedBlockchain(networkId)) return false
val basePath = networkId.derivationPath.value ?: return false
val isEnabled = dynamicAddressesRepository
.isDynamicAddressesEnabledForNetwork(userWalletId, networkId)
.firstOrNull() == true
if (!isEnabled) return false
return DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex(
customPath = path,
basePath = basePath,
)
}
private fun isSupportedBlockchain(networkId: Network.ID): Boolean {
return DynamicAddressesSupportedBlockchains.isSupported(networkId.toBlockchain())
}
}

View file

@ -9,6 +9,7 @@ import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.address.AddressType
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
@ -551,11 +552,7 @@ internal class TokenDetailsModel @Inject constructor(
val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false
if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false
val changeNode = nodes[nodes.size - 2]
val indexNode = nodes.last()
return changeNode.getIndex(includeHardened = false) == 0L &&
indexNode.getIndex(includeHardened = false) == 0L
return DynamicAddressesDerivationChecker.isBaseDerivation(pathValue)
}
private suspend fun isXPUBSupported(): Boolean {