Updated on 2026-08-14
This commit is contained in:
parent
53a84a6aa8
commit
d9f3a8e456
9 changed files with 376 additions and 45 deletions
|
|
@ -1,11 +1,11 @@
|
||||||
package com.tangem.data.dynamicaddresses
|
package com.tangem.data.dynamicaddresses
|
||||||
|
|
||||||
import com.tangem.crypto.hdWallet.DerivationPath
|
|
||||||
import com.tangem.data.common.account.WalletAccountsFetcher
|
import com.tangem.data.common.account.WalletAccountsFetcher
|
||||||
import com.tangem.data.common.account.WalletAccountsSaver
|
import com.tangem.data.common.account.WalletAccountsSaver
|
||||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||||
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
|
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
|
||||||
import com.tangem.domain.account.repository.AccountsCRUDRepository
|
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.model.DynamicAddressesStatus
|
||||||
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
|
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
|
|
@ -97,34 +97,30 @@ internal class DefaultDynamicAddressesRepository(
|
||||||
.flatMap { it.tokens.orEmpty() }
|
.flatMap { it.tokens.orEmpty() }
|
||||||
.any { token ->
|
.any { token ->
|
||||||
val tokenDerivationPath = token.derivationPath ?: return@any false
|
val tokenDerivationPath = token.derivationPath ?: return@any false
|
||||||
token.networkId == network.rawId &&
|
token.networkId == network.id.rawId.value &&
|
||||||
tokenDerivationPath != baseDerivationPath &&
|
tokenDerivationPath != baseDerivationPath &&
|
||||||
hasNonZeroChangeOrIndex(tokenDerivationPath, baseDerivationPath)
|
DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex(
|
||||||
|
customPath = tokenDerivationPath,
|
||||||
|
basePath = baseDerivationPath,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
override fun isDynamicAddressesEnabledForNetwork(
|
||||||
* Checks if the token's derivation path has the same first 3 nodes (purpose/coin/account)
|
userWalletId: UserWalletId,
|
||||||
* as the base path but different change/index nodes (not both 0).
|
networkId: Network.ID,
|
||||||
*/
|
): Flow<Boolean> {
|
||||||
private fun hasNonZeroChangeOrIndex(tokenPath: String, basePath: String): Boolean {
|
return walletAccountsFetcher.get(userWalletId)
|
||||||
val tokenNodes = runCatching { DerivationPath(tokenPath).nodes }.getOrNull() ?: return false
|
.map { response ->
|
||||||
val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false
|
response.accounts
|
||||||
|
.flatMap { it.tokens.orEmpty() }
|
||||||
if (tokenNodes.size < DERIVATION_NODE_COUNT || baseNodes.size < DERIVATION_NODE_COUNT) return false
|
.any { token ->
|
||||||
|
token.matchesNetwork(networkId) &&
|
||||||
// First 3 nodes must match (purpose/coin/account) by value, ignoring hardening
|
token.dynamicAddressesEnabled == true
|
||||||
val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i ->
|
}
|
||||||
tokenNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false)
|
}
|
||||||
}
|
.flowOn(dispatchers.io)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateTokenDynamicAddressesFlag(
|
private suspend fun updateTokenDynamicAddressesFlag(
|
||||||
|
|
@ -137,7 +133,7 @@ internal class DefaultDynamicAddressesRepository(
|
||||||
accounts = response.accounts.map { account ->
|
accounts = response.accounts.map { account ->
|
||||||
account.copy(
|
account.copy(
|
||||||
tokens = account.tokens?.map { token ->
|
tokens = account.tokens?.map { token ->
|
||||||
if (token.matchesNetwork(network)) {
|
if (token.matchesNetwork(network.id)) {
|
||||||
token.copy(dynamicAddressesEnabled = enabled)
|
token.copy(dynamicAddressesEnabled = enabled)
|
||||||
} else {
|
} else {
|
||||||
token
|
token
|
||||||
|
|
@ -157,19 +153,12 @@ internal class DefaultDynamicAddressesRepository(
|
||||||
private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? {
|
private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? {
|
||||||
return accounts
|
return accounts
|
||||||
.flatMap { it.tokens.orEmpty() }
|
.flatMap { it.tokens.orEmpty() }
|
||||||
.find { it.matchesNetwork(network) }
|
.find { it.matchesNetwork(network.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun UserTokensResponse.Token.matchesNetwork(network: Network): Boolean {
|
private fun UserTokensResponse.Token.matchesNetwork(networkId: Network.ID): Boolean {
|
||||||
return networkId == network.rawId &&
|
return this.networkId == networkId.rawId.value &&
|
||||||
derivationPath == network.derivationPath.value &&
|
derivationPath == networkId.derivationPath.value &&
|
||||||
contractAddress == null
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -8,6 +8,10 @@ android {
|
||||||
namespace = "com.tangem.domain.dynamicaddresses"
|
namespace = "com.tangem.domain.dynamicaddresses"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.domain.core)
|
api(projects.domain.core)
|
||||||
api(projects.domain.dynamicAddresses.models)
|
api(projects.domain.dynamicAddresses.models)
|
||||||
|
|
@ -21,4 +25,8 @@ dependencies {
|
||||||
exclude(module = "joda-time")
|
exclude(module = "joda-time")
|
||||||
}
|
}
|
||||||
implementation(tangemDeps.card.core)
|
implementation(tangemDeps.card.core)
|
||||||
|
|
||||||
|
testRuntimeOnly(deps.test.junit5.engine)
|
||||||
|
testImplementation(projects.common.test)
|
||||||
|
testImplementation(projects.test.core)
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,4 +22,7 @@ interface DynamicAddressesRepository {
|
||||||
|
|
||||||
/** Returns true if there are custom tokens with change/index ≠ 0 that conflict with dynamic addresses */
|
/** Returns true if there are custom tokens with change/index ≠ 0 that conflict with dynamic addresses */
|
||||||
suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean
|
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>
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@ dependencies {
|
||||||
implementation(projects.domain.wallets.models)
|
implementation(projects.domain.wallets.models)
|
||||||
implementation(projects.domain.swap.models)
|
implementation(projects.domain.swap.models)
|
||||||
implementation(projects.domain.notifications)
|
implementation(projects.domain.notifications)
|
||||||
|
implementation(projects.domain.dynamicAddresses)
|
||||||
|
|
||||||
// region Project - Libs
|
// region Project - Libs
|
||||||
implementation(projects.libs.blockchainSdk)
|
implementation(projects.libs.blockchainSdk)
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import arrow.core.getOrElse
|
import arrow.core.getOrElse
|
||||||
import com.tangem.core.decompose.context.AppComponentContext
|
import com.tangem.core.decompose.context.AppComponentContext
|
||||||
import com.tangem.core.ui.extensions.resourceReference
|
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.domain.managetokens.ValidateDerivationPathUseCase
|
||||||
import com.tangem.features.managetokens.utils.CardanoDerivationPathValidator
|
|
||||||
import com.tangem.domain.managetokens.model.exceptoin.DerivationPathValidationException
|
import com.tangem.domain.managetokens.model.exceptoin.DerivationPathValidationException
|
||||||
import com.tangem.domain.models.network.Network
|
import com.tangem.domain.models.network.Network
|
||||||
import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent
|
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.entity.customtoken.SelectedDerivationPath
|
||||||
import com.tangem.features.managetokens.impl.R
|
import com.tangem.features.managetokens.impl.R
|
||||||
import com.tangem.features.managetokens.ui.dialog.CustomDerivationInputDialog
|
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.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
|
||||||
|
|
@ -26,9 +30,15 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr
|
||||||
@Assisted context: AppComponentContext,
|
@Assisted context: AppComponentContext,
|
||||||
@Assisted private val params: CustomTokenDerivationInputComponent.Params,
|
@Assisted private val params: CustomTokenDerivationInputComponent.Params,
|
||||||
private val validateDerivationPathUseCase: ValidateDerivationPathUseCase,
|
private val validateDerivationPathUseCase: ValidateDerivationPathUseCase,
|
||||||
|
private val dynamicAddressesRepository: DynamicAddressesRepository,
|
||||||
|
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
|
||||||
) : CustomTokenDerivationInputComponent, AppComponentContext by context {
|
) : CustomTokenDerivationInputComponent, AppComponentContext by context {
|
||||||
|
|
||||||
private val cardanoDerivationPathValidator = CardanoDerivationPathValidator()
|
private val cardanoDerivationPathValidator = CardanoDerivationPathValidator()
|
||||||
|
private val dynamicAddressesDerivationValidator = DynamicAddressesDerivationValidator(
|
||||||
|
dynamicAddressesRepository = dynamicAddressesRepository,
|
||||||
|
dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles,
|
||||||
|
)
|
||||||
|
|
||||||
private val state: MutableStateFlow<CustomDerivationInputUM> = MutableStateFlow(
|
private val state: MutableStateFlow<CustomDerivationInputUM> = MutableStateFlow(
|
||||||
value = getInitialState(),
|
value = getInitialState(),
|
||||||
|
|
@ -52,13 +62,15 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
private fun observeValueUpdates() {
|
private fun observeValueUpdates() {
|
||||||
state
|
state
|
||||||
.map { it.value.text }
|
.map { it.value.text }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.sample(periodMillis = 1_000)
|
.sample(periodMillis = 1_000)
|
||||||
.onEach(::validateValue)
|
.flatMapLatest { value ->
|
||||||
|
flow { emit(validateValue(value)) }
|
||||||
|
}
|
||||||
.launchIn(componentScope)
|
.launchIn(componentScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +82,7 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr
|
||||||
onConfirm = ::confirm,
|
onConfirm = ::confirm,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun validateValue(value: String) {
|
private suspend fun validateValue(value: String) {
|
||||||
validateDerivationPathUseCase(value).getOrElse { e ->
|
validateDerivationPathUseCase(value).getOrElse { e ->
|
||||||
updateWithValidationError(e)
|
updateWithValidationError(e)
|
||||||
return
|
return
|
||||||
|
|
@ -90,6 +102,21 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr
|
||||||
return
|
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.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
error = null,
|
error = null,
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import com.arkivanov.decompose.router.slot.activate
|
||||||
import com.arkivanov.decompose.router.slot.dismiss
|
import com.arkivanov.decompose.router.slot.dismiss
|
||||||
import com.tangem.blockchain.common.address.AddressType
|
import com.tangem.blockchain.common.address.AddressType
|
||||||
import com.tangem.crypto.hdWallet.DerivationPath
|
import com.tangem.crypto.hdWallet.DerivationPath
|
||||||
|
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
|
||||||
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
|
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
|
||||||
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
|
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
|
||||||
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
|
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
|
||||||
|
|
@ -551,11 +552,7 @@ internal class TokenDetailsModel @Inject constructor(
|
||||||
val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false
|
val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false
|
||||||
if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false
|
if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false
|
||||||
|
|
||||||
val changeNode = nodes[nodes.size - 2]
|
return DynamicAddressesDerivationChecker.isBaseDerivation(pathValue)
|
||||||
val indexNode = nodes.last()
|
|
||||||
|
|
||||||
return changeNode.getIndex(includeHardened = false) == 0L &&
|
|
||||||
indexNode.getIndex(includeHardened = false) == 0L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun isXPUBSupported(): Boolean {
|
private suspend fun isXPUBSupported(): Boolean {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue