Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-13 21:46:57 +03:00
parent 16ebea436f
commit f153804a41
7 changed files with 300 additions and 63 deletions

View file

@ -1,5 +1,6 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.crypto.hdWallet.DerivationNode
import com.tangem.crypto.hdWallet.DerivationPath
/**
@ -11,16 +12,24 @@ import com.tangem.crypto.hdWallet.DerivationPath
*/
object DynamicAddressesDerivationChecker {
private const val BIP44_NODE_COUNT = 5
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
fun parseNodes(path: String): List<DerivationNode>? {
return runCatching { DerivationPath(path).nodes }.getOrNull()
}
/**
* @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
val nodes = parseNodes(path) ?: return false
return isBaseDerivation(nodes)
}
fun isBaseDerivation(nodes: List<DerivationNode>): Boolean {
if (nodes.size < BIP44_NODE_COUNT) return false
val change = nodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)

View file

@ -4,17 +4,11 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
/**
* List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode).
* Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
*
* Dynamic addresses are NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses.
* Only the default derivation style per blockchain is supported.
* Whitelist of blockchains eligible for Dynamic Addresses (XPUB-based multi-address mode).
* Mirrors [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
*/
object DynamicAddressesSupportedBlockchains {
private const val BIP44_PURPOSE = 44L
private const val BIP84_PURPOSE = 84L
private val supported = setOf(
Blockchain.Bitcoin,
Blockchain.BitcoinTestnet,
@ -29,26 +23,7 @@ object DynamicAddressesSupportedBlockchains {
private val supportedNetworkIds = supported.map { it.toNetworkId() }.toSet()
/**
* Allowed BIP purpose nodes per network ID.
* BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH).
*/
private val allowedPurposeByNetworkId: Map<String, Long> = buildMap {
put(Blockchain.Bitcoin.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.BitcoinTestnet.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.Litecoin.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.BitcoinCash.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.BitcoinCashTestnet.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Dogecoin.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Dash.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Ravencoin.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.RavencoinTestnet.toNetworkId(), BIP44_PURPOSE)
}
fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported
fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds
/** Returns the allowed BIP purpose node for the given network, or null if not supported */
fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId]
}

View file

@ -0,0 +1,47 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker.BIP44_NODE_COUNT
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.wallets.derivations.derivationStyleProvider
/**
* Whether the Dynamic Addresses menu entry should be shown for a given (wallet, currency) pair.
* Policy check only hardware XPUB capability is verified by [IsXpubSupportedUseCase].
*/
class IsDynamicAddressesAvailableUseCase(
private val featureToggles: DynamicAddressesFeatureToggles,
) {
operator fun invoke(userWallet: UserWallet, cryptoCurrency: CryptoCurrency): Boolean {
if (!featureToggles.isDynamicAddressesEnabled) return false
if (cryptoCurrency !is CryptoCurrency.Coin) return false
val network = cryptoCurrency.network
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.rawId)) return false
return isWalletDefaultDerivation(userWallet, network)
}
private fun isWalletDefaultDerivation(userWallet: UserWallet, network: Network): Boolean {
val actualPath = network.derivationPath.value ?: return false
val actualNodes = DynamicAddressesDerivationChecker.parseNodes(actualPath) ?: return false
if (!DynamicAddressesDerivationChecker.isBaseDerivation(actualNodes)) return false
val style = userWallet.derivationStyleProvider.getDerivationStyle() ?: return false
val expectedPath = network.toBlockchain().derivationPath(style)?.rawPath ?: return false
val expectedNodes = DynamicAddressesDerivationChecker.parseNodes(expectedPath) ?: return false
if (expectedNodes.size < BIP44_NODE_COUNT) return false
// Match purpose + coin_type; account is allowed to differ for secondary accounts.
return actualNodes[PURPOSE_NODE_INDEX] == expectedNodes[PURPOSE_NODE_INDEX] &&
actualNodes[COIN_TYPE_NODE_INDEX] == expectedNodes[COIN_TYPE_NODE_INDEX]
}
private companion object {
const val PURPOSE_NODE_INDEX = 0
const val COIN_TYPE_NODE_INDEX = 1
}
}

View file

@ -0,0 +1,222 @@
package com.tangem.domain.dynamicaddresses
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.toNetworkId
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.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class IsDynamicAddressesAvailableUseCaseTest {
private val featureToggles: DynamicAddressesFeatureToggles = mockk()
private val useCase = IsDynamicAddressesAvailableUseCase(featureToggles)
@BeforeAll
fun setup() {
mockkStatic("com.tangem.domain.wallets.derivations.DerivationStyleProviderExtKt")
every { featureToggles.isDynamicAddressesEnabled } returns true
}
@AfterAll
fun teardown() {
unmockkAll()
}
// region Gating
@Test
fun `feature toggle off returns false`() {
every { featureToggles.isDynamicAddressesEnabled } returns false
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
every { featureToggles.isDynamicAddressesEnabled } returns true // restore
}
@Test
fun `token currency returns false`() {
val token = token(Blockchain.Ethereum, "m/44'/60'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), token)
assertThat(result).isFalse()
}
@Test
fun `unsupported network returns false`() {
val coin = coin(Blockchain.Ethereum, "m/44'/60'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
@Test
fun `non-HD wallet returns false`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
val result = useCase(walletWithStyle(style = null), coin)
assertThat(result).isFalse()
}
// endregion
// region BTC: V2 (Wallet 1) ↔ V3 (Wallet 2 / Hot)
@Test
fun `V3 wallet accepts BIP-84 BTC`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isTrue()
}
@Test
fun `V3 wallet rejects BIP-44 BTC`() {
val coin = coin(Blockchain.Bitcoin, "m/44'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
@Test
fun `V2 wallet accepts BIP-44 BTC`() {
val coin = coin(Blockchain.Bitcoin, "m/44'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
assertThat(result).isTrue()
}
@Test
fun `V2 wallet rejects BIP-84 BTC`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
assertThat(result).isFalse()
}
// endregion
// region LTC: same dual-style behavior
@Test
fun `V3 wallet accepts BIP-84 LTC`() {
val coin = coin(Blockchain.Litecoin, "m/84'/2'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isTrue()
}
@Test
fun `V2 wallet accepts BIP-44 LTC`() {
val coin = coin(Blockchain.Litecoin, "m/44'/2'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
assertThat(result).isTrue()
}
@Test
fun `coin_type mismatch is rejected`() {
// BTC coin_type is 0; using LTC's coin_type 2 must fail
val coin = coin(Blockchain.Bitcoin, "m/84'/2'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
// endregion
// region Other supported chains (V2 and V3 share BIP-44)
@Test
fun `V3 wallet accepts BIP-44 Dogecoin`() {
val coin = coin(Blockchain.Dogecoin, "m/44'/3'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isTrue()
}
@Test
fun `V2 wallet accepts BIP-44 Dash`() {
val coin = coin(Blockchain.Dash, "m/44'/5'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
assertThat(result).isTrue()
}
// endregion
// region Account & non-base path
@Test
fun `secondary account is accepted`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/3'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isTrue()
}
@Test
fun `non-zero change is rejected`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/1/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
@Test
fun `non-zero address index is rejected`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/5")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
@Test
fun `path with fewer than 5 nodes is rejected`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
// endregion
// region helpers
private fun walletWithStyle(style: DerivationStyle?): UserWallet {
val wallet: UserWallet = mockk()
val provider = object : DerivationStyleProvider {
override fun getDerivationStyle(): DerivationStyle? = style
}
every { wallet.derivationStyleProvider } returns provider
return wallet
}
private fun network(blockchain: Blockchain, derivationPathValue: String): Network {
val derivationPath = Network.DerivationPath.Card(derivationPathValue)
return Network(
id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath),
name = blockchain.fullName,
currencySymbol = blockchain.currency,
derivationPath = derivationPath,
isTestnet = blockchain.isTestnet(),
standardType = Network.StandardType.Unspecified(blockchain.fullName),
hasFiatFeeRate = true,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
private fun coin(blockchain: Blockchain, derivationPathValue: String): CryptoCurrency.Coin {
val coin: CryptoCurrency.Coin = mockk()
every { coin.network } returns network(blockchain, derivationPathValue)
return coin
}
private fun token(blockchain: Blockchain, derivationPathValue: String): CryptoCurrency.Token {
val token: CryptoCurrency.Token = mockk()
every { token.network } returns network(blockchain, derivationPathValue)
return token
}
// endregion
}