Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-17 15:32:56 +04:00
parent 2b383418c0
commit 1326b5bab3
41 changed files with 1381 additions and 38 deletions

View file

@ -1,6 +1,13 @@
package com.tangem.tap.di.domain
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.data.wallets.hot.TangemHotWalletSigner
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.walletconnect.WcTransactionSignerProvider
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.WcSessionsUseCase
@ -27,4 +34,27 @@ internal object WalletConnectDomainModule {
fun providesWcSessionsUseCase(sessionsManager: WcSessionsManager): WcSessionsUseCase {
return WcSessionsUseCase(sessionsManager)
}
@Provides
@Singleton
fun providesWcTransactionSignerProvider(
cardSdkConfigRepository: CardSdkConfigRepository,
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
): WcTransactionSignerProvider {
return object : WcTransactionSignerProvider {
override fun createSigner(wallet: UserWallet): TransactionSigner {
return when (wallet) {
is UserWallet.Hot -> tangemHotWalletSignerFactory.create(wallet)
is UserWallet.Cold -> {
val card = wallet.scanResponse.card
val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins
cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = wallet.scanResponse),
)
}
}
}
}
}
}

View file

@ -59,5 +59,9 @@
{
"name": "ADD_AND_MANAGE_TOKENS_ENABLED",
"version": "undefined"
},
{
"name": "WALLET_CONNECT_BITCOIN_ENABLED",
"version": "undefined"
}
]

View file

@ -2150,6 +2150,8 @@
<string name="wc_specify_networks_subtitle">At least one network is required for dApp connection</string>
<string name="wc_specify_networks_title">Specify selected networks</string>
<string name="wc_successfully_signed">Successfully signed</string>
<string name="wc_get_addresses_title">Share Addresses</string>
<string name="wc_get_addresses_addresses_title">Addresses to share</string>
<string name="wc_transaction_flow_title" translatable="false">WalletConnect</string>
<string name="wc_transaction_info_to_title">To</string>
<string name="wc_transaction_request">Transaction request</string>

View file

@ -33,6 +33,7 @@ internal class DefaultBlockAidRepository(
return when (data.params) {
is TransactionParams.Evm -> scanEvmTransaction(data = data)
is TransactionParams.Solana -> scanSolanaTransaction(data = data)
is TransactionParams.Bitcoin -> scanBitcoinTransaction(data = data)
}
}
@ -59,6 +60,17 @@ internal class DefaultBlockAidRepository(
mapper.mapToDomain(response)
}
@Suppress("UnusedParameter")
private fun scanBitcoinTransaction(data: TransactionData): CheckTransactionResult {
// TODO: BlockAid API doesn't support Bitcoin transaction scanning yet
// When support is added, implement: api.scanBitcoinTransaction(mapper.mapToBitcoinRequest(data))
return CheckTransactionResult(
validation = com.domain.blockaid.models.transaction.ValidationResult.FAILED_TO_VALIDATE,
description = "Bitcoin transaction validation is not yet supported by BlockAid",
simulation = com.domain.blockaid.models.transaction.SimulationResult.FailedToSimulate,
)
}
private suspend fun scanEvmTransactionBulk(
blockchain: Blockchain,
transactionDataList: List<SDKTransactionData.Uncompiled>,

View file

@ -34,6 +34,7 @@ dependencies {
/* Project - Core */
implementation(projects.core.utils)
implementation(projects.core.analytics)
api(projects.core.configToggles)
/* DI */
implementation(deps.hilt.core)

View file

@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.DefaultWalletConnectRepository
import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase
import com.tangem.data.walletconnect.network.bitcoin.WcBitcoinNetwork
import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork
import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork
import com.tangem.data.walletconnect.pair.*
@ -25,6 +26,7 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.WcRequestService
import com.tangem.domain.walletconnect.WcRequestUseCaseFactory
import com.tangem.domain.walletconnect.featuretoggle.WalletConnectFeatureToggles
import com.tangem.domain.walletconnect.repository.WalletConnectRepository
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.disconnect.WcDisconnectUseCase
@ -34,6 +36,8 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.walletconnect.featuretoggle.DefaultWalletConnectFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -162,6 +166,22 @@ internal object WalletConnectDataModule {
networksConverter = wcNetworksConverter,
)
@Provides
@Singleton
fun wcBitcoinNetwork(
@SdkMoshi moshi: Moshi,
wcNetworksConverter: WcNetworksConverter,
sessionsManager: WcSessionsManager,
factories: WcBitcoinNetwork.Factories,
walletManagersFacade: WalletManagersFacade,
): WcBitcoinNetwork = WcBitcoinNetwork(
moshi = moshi,
sessionsManager = sessionsManager,
factories = factories,
networksConverter = wcNetworksConverter,
walletManagersFacade = walletManagersFacade,
)
@Provides
@Singleton
fun caipNamespaceDelegate(
@ -198,11 +218,19 @@ internal object WalletConnectDataModule {
@Provides
@Singleton
fun diHelperBox(ethNetwork: WcEthNetwork, solanaNetwork: WcSolanaNetwork) = DiHelperBox(
handlers = setOf(
ethNetwork,
solanaNetwork,
),
fun diHelperBox(
ethNetwork: WcEthNetwork,
solanaNetwork: WcSolanaNetwork,
bitcoinNetwork: WcBitcoinNetwork,
featureToggles: WalletConnectFeatureToggles,
) = DiHelperBox(
handlers = buildSet {
add(ethNetwork)
add(solanaNetwork)
if (featureToggles.isBitcoinEnabled) {
add(bitcoinNetwork)
}
},
)
@Provides
@ -210,10 +238,15 @@ internal object WalletConnectDataModule {
fun namespaceConverters(
ethNamespaceConverter: WcEthNetwork.NamespaceConverter,
solanaNamespaceConverter: WcSolanaNetwork.NamespaceConverter,
): Set<@JvmSuppressWildcards WcNamespaceConverter> = setOf(
ethNamespaceConverter,
solanaNamespaceConverter,
)
bitcoinNamespaceConverter: WcBitcoinNetwork.NamespaceConverter,
featureToggles: WalletConnectFeatureToggles,
): Set<@JvmSuppressWildcards WcNamespaceConverter> = buildSet {
add(ethNamespaceConverter)
add(solanaNamespaceConverter)
if (featureToggles.isBitcoinEnabled) {
add(bitcoinNamespaceConverter)
}
}
@Provides
@Singleton
@ -239,6 +272,14 @@ internal object WalletConnectDataModule {
return WcSolanaNetwork.NamespaceConverter(excludedBlockchains)
}
@Provides
@Singleton
fun wcBitcoinNetworkNamespaceConverter(
excludedBlockchains: ExcludedBlockchains,
): WcBitcoinNetwork.NamespaceConverter {
return WcBitcoinNetwork.NamespaceConverter(excludedBlockchains)
}
@Provides
@Singleton
fun providesWcDisconnectUseCase(
@ -248,6 +289,14 @@ internal object WalletConnectDataModule {
return WcDisconnectUseCase(sessionsManager, analytics)
}
@Provides
@Singleton
fun providesWalletConnectFeatureToggles(
featureTogglesManager: FeatureTogglesManager,
): WalletConnectFeatureToggles {
return DefaultWalletConnectFeatureToggles(featureTogglesManager)
}
internal class DiHelperBox(
val handlers: Set<WcRequestToUseCaseConverter>,
)

View file

@ -0,0 +1,13 @@
package com.tangem.data.walletconnect.featuretoggle
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.walletconnect.featuretoggle.WalletConnectFeatureToggles
internal class DefaultWalletConnectFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : WalletConnectFeatureToggles {
override val isBitcoinEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.WALLET_CONNECT_BITCOIN_ENABLED)
}

View file

@ -9,7 +9,7 @@ import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.pair.WcPairSdkDelegate
import com.tangem.data.walletconnect.request.DefaultWcRequestService
import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.utils.logging.TangemLogger

View file

@ -0,0 +1,112 @@
package com.tangem.data.walletconnect.network.bitcoin
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* JSON request model for sendTransfer method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinSendTransferRequest(
@Json(name = "account")
val account: String,
@Json(name = "recipientAddress")
val recipientAddress: String,
@Json(name = "amount")
val amount: String,
@Json(name = "memo")
val memo: String? = null,
@Json(name = "changeAddress")
val changeAddress: String? = null,
)
/**
* JSON request model for getAccountAddresses method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinGetAccountAddressesRequest(
@Json(name = "account")
val account: String? = null,
@Json(name = "intentions")
val intentions: List<String>? = null,
)
/**
* JSON request model for signPsbt method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinSignPsbtRequest(
@Json(name = "psbt")
val psbt: String,
@Json(name = "signInputs")
val signInputs: List<WcBitcoinSignInput>,
@Json(name = "broadcast")
val isBroadcast: Boolean? = false,
)
/**
* JSON model for sign input specification.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinSignInput(
@Json(name = "address")
val address: String,
@Json(name = "index")
val index: Int,
@Json(name = "sighashTypes")
val sighashTypes: List<Int>? = null,
)
/**
* JSON request model for signMessage method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinSignMessageRequest(
@Json(name = "account")
val account: String,
@Json(name = "message")
val message: String,
@Json(name = "address")
val address: String? = null,
@Json(name = "protocol")
val protocol: String? = "ecdsa",
)
/**
* JSON response model for getAccountAddresses method.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinGetAccountAddressesResponse(
@Json(name = "addresses")
val addresses: List<WcBitcoinAddressInfo>,
)
/**
* Address information in getAccountAddresses response.
*/
@JsonClass(generateAdapter = true)
internal data class WcBitcoinAddressInfo(
@Json(name = "address")
val address: String,
@Json(name = "publicKey")
val publicKey: String? = null,
@Json(name = "path")
val path: String? = null,
@Json(name = "intention")
val intention: String? = null,
)

View file

@ -0,0 +1,118 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.AccountAddress
import com.tangem.blockchain.extensions.Result as SdkResult
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.datasource.di.SdkMoshi
import com.tangem.domain.models.network.Network
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.model.WcSession
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.usecase.method.WcGetAddressesUseCase
import com.tangem.domain.walletconnect.usecase.method.WcNetworkDerivationState
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Use case for Bitcoin getAccountAddresses WalletConnect method.
*
* Returns wallet addresses filtered by intention (payment/ordinal).
* This is a non-signing operation.
*/
@JsonClass(generateAdapter = true)
internal data class AddressInfo(
@Json(name = "address") val address: String,
@Json(name = "publicKey") val publicKey: String? = null,
@Json(name = "path") val path: String? = null,
@Json(name = "intention") val intention: String? = null,
)
internal class WcBitcoinGetAccountAddressesUseCase @AssistedInject constructor(
@Assisted val context: WcMethodUseCaseContext,
@Assisted override val method: WcBitcoinMethod.GetAccountAddresses,
private val walletManagersFacade: WalletManagersFacade,
private val respondService: WcRespondService,
@SdkMoshi private val moshi: Moshi,
) : WcGetAddressesUseCase {
override val wallet get() = context.session.wallet
override val session: WcSession
get() = context.session
override val rawSdkRequest: WcSdkSessionRequest
get() = context.rawSdkRequest
override val network: Network
get() = context.network
override val derivationState: WcNetworkDerivationState = when {
context.networkDerivationsCount > 1 -> WcNetworkDerivationState.Multiple(walletAddress = context.accountAddress)
else -> WcNetworkDerivationState.Single
}
override suspend fun invoke(): Either<HandleMethodError, WcGetAddressesUseCase.GetAddressesResult> {
val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network)
?: return HandleMethodError.UnknownError("Failed to create wallet manager").left()
return when (val result = walletManager.getAddresses(filterOptions = method.intentions)) {
is SdkResult.Success -> {
val accountAddresses = result.data.map { addressInfo ->
AccountAddress(
address = addressInfo.address,
publicKey = addressInfo.publicKey,
path = addressInfo.derivationPath,
intention = addressInfo.metadata?.get("intention") as? String,
)
}
val response = buildJsonResponse(accountAddresses)
respondService.respond(rawSdkRequest, response)
WcGetAddressesUseCase.GetAddressesResult(
addresses = accountAddresses.map { addr ->
WcGetAddressesUseCase.AddressInfo(
address = addr.address,
publicKey = addr.publicKey,
path = addr.path,
intention = addr.intention,
)
},
).right()
}
is SdkResult.Failure -> {
HandleMethodError.UnknownError(result.error.customMessage).left()
}
}
}
override fun reject() {
respondService.rejectRequestNonBlock(rawSdkRequest)
}
private fun buildJsonResponse(accountAddresses: List<AccountAddress>): String {
val addresses = accountAddresses.map { addr ->
AddressInfo(
address = addr.address,
publicKey = addr.publicKey,
path = addr.path,
intention = addr.intention,
)
}
return moshi.adapter<List<AddressInfo>>(
com.squareup.moshi.Types.newParameterizedType(List::class.java, AddressInfo::class.java),
).toJson(addresses)
}
@AssistedFactory
interface Factory {
fun create(
context: WcMethodUseCaseContext,
method: WcBitcoinMethod.GetAccountAddresses,
): WcBitcoinGetAccountAddressesUseCase
}
}

View file

@ -0,0 +1,214 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.walletconnect.model.CAIP2
import com.tangem.data.walletconnect.model.NamespaceKey
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter
import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.model.WcBitcoinMethodName
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.repository.WcSessionsManager
import com.tangem.domain.walletconnect.usecase.method.WcMethodUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import jakarta.inject.Inject
/**
* WalletConnect request handler for Bitcoin blockchain.
*
* Handles Bitcoin-specific RPC methods: sendTransfer, getAccountAddresses, signPsbt, signMessage.
*
* @see <a href="https://docs.reown.com/advanced/multichain/rpc-reference/bitcoin-rpc">Bitcoin RPC Reference</a>
*/
internal class WcBitcoinNetwork(
private val moshi: Moshi,
private val sessionsManager: WcSessionsManager,
private val factories: Factories,
private val networksConverter: WcNetworksConverter,
private val walletManagersFacade: WalletManagersFacade,
) : WcRequestToUseCaseConverter {
override fun toWcMethodName(request: WcSdkSessionRequest): WcBitcoinMethodName? {
val methodKey = request.request.method
return WcBitcoinMethodName.entries.find { it.raw == methodKey }
}
@Suppress("CyclomaticComplexMethod")
override suspend fun toUseCase(request: WcSdkSessionRequest): Either<HandleMethodError, WcMethodUseCase> {
fun error(message: String) = HandleMethodError.UnknownError(message).left()
val name = toWcMethodName(request) ?: return error("Unknown method name")
val method: WcBitcoinMethod = name.toMethod(request)
.getOrElse { return error(it.message.orEmpty()) }
?: return error("Failed to parse $name")
val session = sessionsManager.findSessionByTopic(request.topic)
?: return HandleMethodError.UnknownSession.left()
val wallet = session.wallet
val chainId = request.chainId.orEmpty()
val account = session.account
suspend fun anyExistNetwork() = networksConverter.mainOrAnyWalletNetworkForRequest(
rawChainId = chainId,
account = account,
)
suspend fun anyAddress() = anyExistNetwork()
?.let { network -> walletManagersFacade.getDefaultAddress(wallet.walletId, network).orEmpty() }
.orEmpty()
val accountAddress = when (method) {
is WcBitcoinMethod.SendTransfer -> method.account
is WcBitcoinMethod.GetAccountAddresses -> method.account
is WcBitcoinMethod.SignPsbt -> method.signInputs.firstOrNull()?.address ?: anyAddress()
is WcBitcoinMethod.SignMessage -> method.address ?: method.account
}
val walletNetwork = networksConverter
.findWalletNetworkForRequest(request, session, accountAddress)
?: anyExistNetwork()
?: return error("Failed to find walletNetwork for accountAddress $accountAddress")
val context = WcMethodUseCaseContext(
session = session,
rawSdkRequest = request,
network = walletNetwork,
accountAddress = accountAddress,
networkDerivationsCount = networksConverter.filterWalletNetworkForRequest(
rawChainId = chainId,
account = account,
).size,
)
val useCase = when (method) {
is WcBitcoinMethod.SendTransfer -> factories.sendTransfer.create(context, method)
is WcBitcoinMethod.GetAccountAddresses -> factories.getAccountAddresses.create(context, method)
is WcBitcoinMethod.SignPsbt -> factories.signPsbt.create(context, method)
is WcBitcoinMethod.SignMessage -> factories.signMessage.create(context, method)
}
return useCase.right()
}
private fun WcBitcoinMethodName.toMethod(request: WcSdkSessionRequest): Either<Throwable, WcBitcoinMethod?> {
val rawParams = request.request.params
return when (this) {
WcBitcoinMethodName.SendTransfer -> moshi.fromJson<WcBitcoinSendTransferRequest>(rawParams)
.getOrElse { return it.left() }
?.let { req ->
WcBitcoinMethod.SendTransfer(
account = req.account,
recipientAddress = req.recipientAddress,
amount = req.amount,
memo = req.memo,
changeAddress = req.changeAddress,
)
}
WcBitcoinMethodName.GetAccountAddresses -> moshi.fromJson<WcBitcoinGetAccountAddressesRequest>(rawParams)
.getOrElse { return it.left() }
?.let { req ->
WcBitcoinMethod.GetAccountAddresses(
account = req.account.orEmpty(),
intentions = req.intentions,
)
}
WcBitcoinMethodName.SignPsbt -> moshi.fromJson<WcBitcoinSignPsbtRequest>(rawParams)
.getOrElse { return it.left() }
?.let { req ->
WcBitcoinMethod.SignPsbt(
psbt = req.psbt,
signInputs = req.signInputs.map { input ->
WcBitcoinMethod.SignInput(
address = input.address,
index = input.index,
sighashTypes = input.sighashTypes,
)
},
shouldBroadcast = req.isBroadcast == true,
)
}
WcBitcoinMethodName.SignMessage -> moshi.fromJson<WcBitcoinSignMessageRequest>(rawParams)
.getOrElse { return it.left() }
?.let { req ->
WcBitcoinMethod.SignMessage(
account = req.account,
message = req.message,
address = req.address,
protocol = req.protocol ?: "ecdsa",
)
}
}.right()
}
/**
* Bitcoin namespace converter for CAIP-2 chain IDs.
*
* Bitcoin uses BIP-122 namespace with genesis block hash as reference.
* Example: bip122:000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f
*/
internal class NamespaceConverter @Inject constructor(
override val excludedBlockchains: ExcludedBlockchains,
) : WcNamespaceConverter {
override val namespaceKey: NamespaceKey = NamespaceKey(NAMESPACE)
override fun toBlockchain(chainId: CAIP2): Blockchain? {
if (chainId.namespace != namespaceKey.key) return null
return when {
isMainnetReference(chainId.reference) -> Blockchain.Bitcoin
isTestnetReference(chainId.reference) -> Blockchain.BitcoinTestnet
else -> null
}
}
private fun isMainnetReference(reference: String): Boolean {
return MAINNET_GENESIS_PREFIX.any { reference.startsWith(it, ignoreCase = true) } ||
reference.equals("mainnet", ignoreCase = true)
}
private fun isTestnetReference(reference: String): Boolean {
return TESTNET_GENESIS_PREFIX.any { reference.startsWith(it, ignoreCase = true) } ||
reference.equals("testnet", ignoreCase = true)
}
companion object {
private const val NAMESPACE = "bip122"
// Bitcoin mainnet genesis hash prefixes (supports any truncated version)
private val MAINNET_GENESIS_PREFIX = listOf(
"000000000019d6689c085ae165831e93", // Mainnet genesis hash prefix (min 32 chars for uniqueness)
)
// Bitcoin testnet genesis hash prefixes (supports any truncated version)
private val TESTNET_GENESIS_PREFIX = listOf(
"000000000933ea01ad0ee984209779ba", // Standard testnet genesis hash prefix (9 leading zeros)
"0000000000933ea01ad0ee984209779ba", // Alternative testnet prefix (10 leading zeros)
)
}
}
/**
* Factory classes for creating Bitcoin WalletConnect use cases.
*/
internal class Factories @Inject constructor(
val sendTransfer: WcBitcoinSendTransferUseCase.Factory,
val getAccountAddresses: WcBitcoinGetAccountAddressesUseCase.Factory,
val signPsbt: WcBitcoinSignPsbtUseCase.Factory,
val signMessage: WcBitcoinSignMessageUseCase.Factory,
)
companion object {
private const val NAMESPACE = "bip122"
}
}

View file

@ -0,0 +1,146 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.left
import com.tangem.blockchain.blockchains.bitcoin.BitcoinTransactionExtras
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.Result as SdkResult
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.walletconnect.WcTransactionSignerProvider
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck
import com.tangem.domain.walletconnect.usecase.method.WcMutableFee
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
/**
* Use case for Bitcoin sendTransfer WalletConnect method.
*
* Sends a Bitcoin transfer transaction with optional memo (OP_RETURN) and custom change address.
*/
@Suppress("LongParameterList")
internal class WcBitcoinSendTransferUseCase @AssistedInject constructor(
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcBitcoinMethod.SendTransfer,
private val walletManagersFacade: WalletManagersFacade,
private val signerProvider: WcTransactionSignerProvider,
override val respondService: WcRespondService,
override val analytics: AnalyticsEventHandler,
blockAidDelegate: BlockAidVerificationDelegate,
) : BaseWcSignUseCase<WcBitcoinTxAction, TransactionData>(),
WcTransactionUseCase,
WcMutableFee {
override val wallet get() = context.session.wallet
private val transferAmount: Amount by lazy {
createAmountFromSatoshis(method.amount)
}
override val securityStatus: LceFlow<Throwable, BlockAidTransactionCheck.Result> =
blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
).map { lce ->
lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) }
}
override suspend fun SignCollector<TransactionData>.onSign(state: WcSignState<TransactionData>) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network)
?: run {
emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left()))
return
}
val signer = signerProvider.createSigner(wallet)
when (val result = walletManager.send(state.signModel, signer)) {
is SdkResult.Success -> {
val response = buildJsonResponse(result.data.hash)
val wcRespondResult = respondService.respond(rawSdkRequest, response)
emit(state.toResult(wcRespondResult))
}
is SdkResult.Failure -> {
emit(state.toResult(HandleMethodError.UnknownError(result.error.customMessage).left()))
}
}
}
override suspend fun FlowCollector<TransactionData>.onMiddleAction(
signModel: TransactionData,
action: WcBitcoinTxAction,
) {
val uncompiled = signModel as? TransactionData.Uncompiled ?: return
val newState = when (action) {
is WcBitcoinTxAction.UpdateFee -> uncompiled.copy(fee = action.fee)
}
emit(newState)
}
override suspend fun dAppFee(): Fee? = null
override fun updateFee(fee: Fee) {
middleAction(WcBitcoinTxAction.UpdateFee(fee))
}
override fun invoke(): Flow<WcSignState<TransactionData>> = flow {
val fee = dAppFee()
val transactionData = createTransactionData(fee)
emitAll(delegate.invoke(transactionData))
}
private fun createTransactionData(fee: Fee?): TransactionData.Uncompiled {
return TransactionData.Uncompiled(
amount = transferAmount,
fee = fee,
sourceAddress = context.accountAddress,
destinationAddress = method.recipientAddress,
extras = BitcoinTransactionExtras(
memo = method.memo,
changeAddress = method.changeAddress,
),
)
}
private fun createAmountFromSatoshis(satoshis: String): Amount {
val btcValue = BigDecimal(satoshis).divide(SATOSHI_IN_BTC)
return Amount(
currencySymbol = network.currencySymbol,
value = btcValue,
decimals = BITCOIN_DECIMALS,
)
}
private fun buildJsonResponse(txid: String): String = "{\"txid\":\"$txid\"}"
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SendTransfer): WcBitcoinSendTransferUseCase
}
private companion object {
val SATOSHI_IN_BTC = BigDecimal("100000000")
const val BITCOIN_DECIMALS = 8
}
}

View file

@ -0,0 +1,117 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.left
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.tangem.blockchain.extensions.Result as SdkResult
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.datasource.di.SdkMoshi
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.SimulationResult
import com.domain.blockaid.models.transaction.ValidationResult
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.walletconnect.WcTransactionSignerProvider
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* Use case for Bitcoin signMessage WalletConnect method.
*
* Signs an arbitrary message using Bitcoin message signing format (BIP-137 ECDSA).
*/
@JsonClass(generateAdapter = true)
internal data class SignMessageResponse(
@Json(name = "address") val address: String,
@Json(name = "signature") val signature: String,
@Json(name = "messageHash") val messageHash: String? = null,
)
@Suppress("LongParameterList")
internal class WcBitcoinSignMessageUseCase @AssistedInject constructor(
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcBitcoinMethod.SignMessage,
private val walletManagersFacade: WalletManagersFacade,
private val signerProvider: WcTransactionSignerProvider,
override val respondService: WcRespondService,
override val analytics: AnalyticsEventHandler,
@SdkMoshi private val moshi: Moshi,
) : BaseWcSignUseCase<Nothing, WcMessageSignUseCase.SignModel>(),
WcMessageSignUseCase {
override val wallet get() = context.session.wallet
// BlockAid doesn't support Bitcoin message signing
override val securityStatus: LceFlow<Throwable, CheckTransactionResult> = flowOf(
Lce.Content(
CheckTransactionResult(
validation = ValidationResult.FAILED_TO_VALIDATE,
simulation = SimulationResult.FailedToSimulate,
),
),
)
override suspend fun SignCollector<WcMessageSignUseCase.SignModel>.onSign(
state: WcSignState<WcMessageSignUseCase.SignModel>,
) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network)
?: run {
emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left()))
return
}
val signer = signerProvider.createSigner(wallet)
// Use the address from method, or fallback to account if not specified
val addressToSign = method.address ?: method.account
// Use MessageSigner to sign the message
when (val result = walletManager.signMessage(
message = method.message,
address = addressToSign,
protocol = method.protocol,
signer = signer,
)) {
is SdkResult.Success -> {
val response = buildJsonResponse(result.data)
val wcRespondResult = respondService.respond(rawSdkRequest, response)
emit(state.toResult(wcRespondResult))
}
is SdkResult.Failure -> {
emit(state.toResult(HandleMethodError.UnknownError(result.error.customMessage).left()))
}
}
}
override fun invoke(): Flow<WcSignState<WcMessageSignUseCase.SignModel>> {
return delegate.invoke(initModel = WcMessageSignUseCase.SignModel(method.message))
}
private fun buildJsonResponse(data: com.tangem.blockchain.common.messagesigning.MessageSignatureResult): String {
val response = SignMessageResponse(
address = data.address,
signature = data.signature,
messageHash = data.messageHash,
)
return moshi.adapter(SignMessageResponse::class.java).toJson(response)
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SignMessage): WcBitcoinSignMessageUseCase
}
}

View file

@ -0,0 +1,142 @@
package com.tangem.data.walletconnect.network.bitcoin
import arrow.core.left
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.SignInput
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.extensions.Result as SdkResult
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.datasource.di.SdkMoshi
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.walletconnect.WcTransactionSignerProvider
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Use case for Bitcoin signPsbt WalletConnect method.
*
* Signs a Partially Signed Bitcoin Transaction (BIP-174 PSBT) with optional broadcast.
*/
@JsonClass(generateAdapter = true)
internal data class SignPsbtResponse(
@Json(name = "psbt") val psbt: String,
@Json(name = "txid") val txid: String? = null,
)
@Suppress("LongParameterList")
internal class WcBitcoinSignPsbtUseCase @AssistedInject constructor(
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcBitcoinMethod.SignPsbt,
private val walletManagersFacade: WalletManagersFacade,
private val signerProvider: WcTransactionSignerProvider,
override val respondService: WcRespondService,
override val analytics: AnalyticsEventHandler,
blockAidDelegate: BlockAidVerificationDelegate,
@SdkMoshi private val moshi: Moshi,
) : BaseWcSignUseCase<Nothing, TransactionData>(),
WcTransactionUseCase {
override val wallet get() = context.session.wallet
override val securityStatus: LceFlow<Throwable, BlockAidTransactionCheck.Result> =
blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = context.accountAddress,
).map { lce ->
lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) }
}
override suspend fun SignCollector<TransactionData>.onSign(state: WcSignState<TransactionData>) {
// Update wallet manager to refresh UTXO data before processing Bitcoin transaction
walletManagersFacade.update(
userWalletId = wallet.walletId,
network = network,
extraTokens = emptySet(),
)
val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network)
?: run {
emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left()))
return
}
val signer = signerProvider.createSigner(wallet)
val signInputs = method.signInputs.map { input ->
SignInput(
address = input.address,
index = input.index,
sighashTypes = input.sighashTypes,
)
}
val signedPsbtResult = walletManager.signPsbt(
psbtBase64 = method.psbt,
signInputs = signInputs,
signer = signer,
)
when (signedPsbtResult) {
is SdkResult.Success -> {
val signedPsbt = signedPsbtResult.data
val txid = if (method.shouldBroadcast) {
when (val broadcastResult = walletManager.broadcastPsbt(signedPsbt)) {
is SdkResult.Success -> broadcastResult.data
is SdkResult.Failure -> {
val error = HandleMethodError.UnknownError(broadcastResult.error.customMessage).left()
emit(state.toResult(error))
return
}
}
} else {
null
}
val response = buildJsonResponse(signedPsbt, txid)
val wcRespondResult = respondService.respond(rawSdkRequest, response)
emit(state.toResult(wcRespondResult))
}
is SdkResult.Failure -> {
emit(state.toResult(HandleMethodError.UnknownError(signedPsbtResult.error.customMessage).left()))
}
}
}
override fun invoke(): Flow<WcSignState<TransactionData>> {
val transactionData = TransactionData.Compiled(
value = TransactionData.Compiled.Data.RawString(method.psbt),
)
return delegate.invoke(transactionData)
}
private fun buildJsonResponse(signedPsbt: String, txid: String?): String {
val response = SignPsbtResponse(
psbt = signedPsbt,
txid = txid,
)
return moshi.adapter(SignPsbtResponse::class.java).toJson(response)
}
@AssistedFactory
interface Factory {
fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SignPsbt): WcBitcoinSignPsbtUseCase
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.data.walletconnect.network.bitcoin
import com.tangem.blockchain.common.transaction.Fee
sealed interface WcBitcoinTxAction {
data class UpdateFee(val fee: Fee) : WcBitcoinTxAction
}

View file

@ -2,7 +2,7 @@ package com.tangem.data.walletconnect.network.solana
import com.tangem.blockchain.extensions.decodeBase58
import com.tangem.blockchain.extensions.encodeBase64NoWrap
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.utils.converter.Converter
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject

View file

@ -8,7 +8,7 @@ import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
import com.reown.walletkit.client.Wallet
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.getDappOriginUrl
import com.tangem.domain.blockaid.BlockAidVerifier
import com.tangem.domain.walletconnect.WcAnalyticEvents

View file

@ -5,7 +5,7 @@ import arrow.core.left
import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.getDappOriginUrl
import com.tangem.datasource.local.walletconnect.WalletConnectStore

View file

@ -1,8 +1,9 @@
package com.tangem.data.walletconnect.request
import com.reown.walletkit.client.Wallet
import com.tangem.data.walletconnect.BuildConfig
import com.tangem.data.walletconnect.respond.DefaultWcRespondService
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter
import com.tangem.data.walletconnect.utils.getDappOriginUrl
@ -43,6 +44,7 @@ internal class DefaultWcRequestService(
respondService.rejectRequestNonBlock(sr)
if (name.raw.startsWith("wallet_")) return
}
_wcRequest.trySend(name to sr)
}
@ -62,6 +64,11 @@ internal class DefaultWcRequestService(
}
private fun saveRequest(request: WcSdkSessionRequest) {
// Skip caching in debug builds since filtering is disabled
if (BuildConfig.DEBUG) {
return
}
val hash = respondService.sessionRequestHash(request)
val now = DateTime.now().millis
respondService.cachedRequest.update { it + (now to hash) }

View file

@ -4,7 +4,7 @@ import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.domain.walletconnect.WcAnalyticEvents
import com.tangem.domain.walletconnect.WcRequestUseCaseFactory

View file

@ -7,7 +7,7 @@ import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.toHexString
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.domain.walletconnect.model.WcRequestError
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.utils.logging.TangemLogger

View file

@ -6,7 +6,7 @@ import arrow.core.right
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.utils.WC_TAG
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.WcSdkSessionConverter

View file

@ -29,6 +29,9 @@ internal object BlockAidChainNameConverter : Converter<Network, String?> {
Blockchain.Solana -> "mainnet"
Blockchain.Bitcoin -> "bitcoin"
Blockchain.BitcoinTestnet -> "bitcoin-testnet"
else -> null
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.domain.blockaid.BlockAidVerifier
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.lce.LceFlow
import com.tangem.domain.models.network.Network
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.model.WcMethod
import com.tangem.domain.walletconnect.model.WcSession
@ -26,10 +27,8 @@ internal class BlockAidVerificationDelegate @Inject constructor(
session: WcSession,
accountAddress: String?,
): LceFlow<Throwable, CheckTransactionResult> = flow {
val failedResult = CheckTransactionResult(
validation = ValidationResult.FAILED_TO_VALIDATE,
simulation = SimulationResult.FailedToSimulate,
)
val failedResult = createFailedResult()
if (accountAddress.isNullOrEmpty()) {
emit(Lce.Content(failedResult))
return@flow
@ -43,18 +42,22 @@ internal class BlockAidVerificationDelegate @Inject constructor(
val methodName = when (method) {
is WcEthMethod -> rawSdkRequest.request.method
is WcSolanaMethod -> method.trimmedPrefixMethodName
is WcBitcoinMethod -> rawSdkRequest.request.method
is WcMethod.Unsupported -> {
emit(Lce.Content(failedResult))
return@flow
}
}
val params = when (method) {
is WcEthMethod -> TransactionParams.Evm(rawSdkRequest.request.params)
is WcSolanaMethod.SignAllTransaction -> TransactionParams.Solana(method.transaction)
is WcSolanaMethod.SignTransaction -> TransactionParams.Solana(listOf(method.transaction))
is WcSolanaMethod.SignMessage -> {
// BlockAid doesn't support solana_signMessage
emit(Lce.Content(failedResult))
is WcSolanaMethod.SignMessage,
is WcBitcoinMethod,
-> {
// BlockAid doesn't support Solana message signing and Bitcoin methods
emit(Lce.Content(createSafeResult()))
return@flow
}
else -> {
@ -81,4 +84,14 @@ internal class BlockAidVerificationDelegate @Inject constructor(
},
)
}
private fun createFailedResult() = CheckTransactionResult(
validation = ValidationResult.FAILED_TO_VALIDATE,
simulation = SimulationResult.FailedToSimulate,
)
private fun createSafeResult() = CheckTransactionResult(
validation = ValidationResult.SAFE,
simulation = SimulationResult.FailedToSimulate,
)
}

View file

@ -3,8 +3,6 @@ package com.tangem.data.walletconnect.utils
import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
const val WC_TAG = "Wallet Connect"
internal interface WcSdkObserver : WalletKit.WalletDelegate {
override val onSessionAuthenticate: ((Wallet.Model.SessionAuthenticate, Wallet.Model.VerifyContext) -> Unit)?

View file

@ -33,4 +33,13 @@ sealed class TransactionParams {
data class Solana(
val transactions: List<String>,
) : TransactionParams()
/**
* Parameters for Bitcoin transactions
*
* @property params JSON-encoded transaction parameters
*/
data class Bitcoin(
val params: String,
) : TransactionParams()
}

View file

@ -0,0 +1,93 @@
package com.tangem.domain.walletconnect.model
/**
* Bitcoin WalletConnect method names.
*
* @see <a href="https://docs.reown.com/advanced/multichain/rpc-reference/bitcoin-rpc">Bitcoin RPC Reference</a>
*/
enum class WcBitcoinMethodName(override val raw: String) : WcMethodName {
SendTransfer("sendTransfer"),
GetAccountAddresses("getAccountAddresses"),
SignPsbt("signPsbt"),
SignMessage("signMessage"),
}
/**
* Bitcoin WalletConnect methods.
*/
sealed interface WcBitcoinMethod : WcMethod {
val methodName: String
/**
* Send a Bitcoin transfer transaction.
*
* @property account Source address (SegWit)
* @property recipientAddress Destination address
* @property amount Amount in satoshis
* @property memo Optional OP_RETURN memo
* @property changeAddress Optional custom change address
*/
data class SendTransfer(
val account: String,
val recipientAddress: String,
val amount: String,
val memo: String?,
val changeAddress: String?,
) : WcBitcoinMethod {
override val methodName: String = WcBitcoinMethodName.SendTransfer.raw
}
/**
* Get account addresses filtered by intention.
*
* @property account Connected account address
* @property intentions Optional filter ("payment", "ordinal")
*/
data class GetAccountAddresses(
val account: String,
val intentions: List<String>?,
) : WcBitcoinMethod {
override val methodName: String = WcBitcoinMethodName.GetAccountAddresses.raw
}
/**
* Sign a PSBT (BIP-174).
*
* @property psbt PSBT in Base64 encoding
* @property signInputs List of inputs to sign
* @property shouldBroadcast Whether to broadcast after signing
*/
data class SignPsbt(
val psbt: String,
val signInputs: List<SignInput>,
val shouldBroadcast: Boolean,
) : WcBitcoinMethod {
override val methodName: String = WcBitcoinMethodName.SignPsbt.raw
}
/**
* Sign input specification for PSBT.
*/
data class SignInput(
val address: String,
val index: Int,
val sighashTypes: List<Int>?,
)
/**
* Sign an arbitrary message using Bitcoin message signing format.
*
* @property account Connected account address
* @property message Message to sign
* @property address Optional specific address to sign with
* @property protocol Signing protocol ("ecdsa" or "bip322")
*/
data class SignMessage(
val account: String,
val message: String,
val address: String?,
val protocol: String,
) : WcBitcoinMethod {
override val methodName: String = WcBitcoinMethodName.SignMessage.raw
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.domain.walletconnect
/**
* Common log tag for all WalletConnect-related logging across modules.
*/
const val WC_TAG = "WalletConnect"

View file

@ -0,0 +1,21 @@
package com.tangem.domain.walletconnect
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.domain.models.wallet.UserWallet
/**
* Provider for creating transaction signers for WalletConnect operations.
*
* This interface abstracts the creation of [TransactionSigner] instances
* to avoid direct dependency on card SDK configuration in wallet-connect module.
*/
interface WcTransactionSignerProvider {
/**
* Creates a transaction signer for the given wallet.
*
* @param wallet The user wallet to create a signer for
* @return A [TransactionSigner] instance for the wallet
*/
fun createSigner(wallet: UserWallet): TransactionSigner
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.walletconnect.featuretoggle
interface WalletConnectFeatureToggles {
val isBitcoinEnabled: Boolean
}

View file

@ -0,0 +1,41 @@
package com.tangem.domain.walletconnect.usecase.method
import arrow.core.Either
import com.tangem.domain.walletconnect.model.HandleMethodError
/**
* Base use case for WalletConnect methods that return wallet addresses.
*
* This is a non-signing operation that returns addresses immediately.
*/
interface WcGetAddressesUseCase : WcMethodUseCase, WcMethodContext {
/**
* Get wallet addresses.
*
* @return Either error or list of addresses with their metadata
*/
suspend operator fun invoke(): Either<HandleMethodError, GetAddressesResult>
/**
* Reject the request.
*/
fun reject()
/**
* Result containing wallet addresses.
*/
data class GetAddressesResult(
val addresses: List<AddressInfo>,
)
/**
* Address information.
*/
data class AddressInfo(
val address: String,
val publicKey: String?,
val path: String?,
val intention: String?,
)
}

View file

@ -21,6 +21,7 @@ import com.tangem.features.walletconnect.components.WcRoutingComponent
import com.tangem.features.walletconnect.connections.components.AlertsComponent
import com.tangem.features.walletconnect.connections.components.AlertsComponent.AlertType.*
import com.tangem.features.walletconnect.connections.components.WcPairComponent
import com.tangem.features.walletconnect.transaction.components.addresses.WcGetAddressesComponent
import com.tangem.features.walletconnect.transaction.components.chain.WcAddNetworkContainerComponent
import com.tangem.features.walletconnect.transaction.components.chain.WcSwitchNetworkComponent
import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams
@ -79,6 +80,10 @@ internal class DefaultWcRoutingComponent @AssistedInject constructor(
appComponentContext = childContext,
params = WcTransactionModelParams(config.rawRequest),
)
is WcInnerRoute.GetAddresses -> WcGetAddressesComponent(
appComponentContext = childContext,
params = WcTransactionModelParams(config.rawRequest),
)
is WcInnerRoute.Send -> WcSendTransactionContainerComponent(
appComponentContext = childContext,
params = WcTransactionModelParams(config.rawRequest),

View file

@ -25,6 +25,9 @@ internal sealed interface WcInnerRoute : Route {
@Serializable
data class SwitchNetwork(override val rawRequest: WcSdkSessionRequest) : Method
@Serializable
data class GetAddresses(override val rawRequest: WcSdkSessionRequest) : Method
@Serializable
data class Pair(val request: WcPairRequest) : WcInnerRoute

View file

@ -7,11 +7,13 @@ import com.tangem.core.decompose.model.Model
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.WcRequestService
import com.tangem.domain.walletconnect.model.WcBitcoinMethodName
import com.tangem.domain.walletconnect.model.WcEthMethodName
import com.tangem.domain.walletconnect.model.WcMethodName
import com.tangem.domain.walletconnect.model.WcSolanaMethodName
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@ModelScoped
@ -32,6 +34,7 @@ internal class WcRoutingModel @Inject constructor(
}
fun onSlotEmpty() {
TangemLogger.d("WC Queue: onSlotEmpty() called")
isSlotEmpty.update { true }
}
@ -44,18 +47,35 @@ internal class WcRoutingModel @Inject constructor(
WcEthMethodName.SignTypeData,
WcEthMethodName.SignTypeDataV4,
WcSolanaMethodName.SignMessage,
-> WcInnerRoute.SignMessage(rawRequest)
WcBitcoinMethodName.SignMessage,
-> {
WcInnerRoute.SignMessage(rawRequest)
}
WcEthMethodName.AddEthereumChain,
-> WcInnerRoute.AddNetwork(rawRequest)
-> {
WcInnerRoute.AddNetwork(rawRequest)
}
WcEthMethodName.SwitchEthereumChain,
-> WcInnerRoute.SwitchNetwork(rawRequest)
-> {
WcInnerRoute.SwitchNetwork(rawRequest)
}
WcEthMethodName.SignTransaction,
WcEthMethodName.SendTransaction,
WcSolanaMethodName.SignTransaction,
WcSolanaMethodName.SendAllTransaction,
-> WcInnerRoute.Send(rawRequest)
WcBitcoinMethodName.SendTransfer,
WcBitcoinMethodName.SignPsbt,
-> {
WcInnerRoute.Send(rawRequest)
}
WcBitcoinMethodName.GetAccountAddresses,
-> {
WcInnerRoute.GetAddresses(rawRequest)
}
is WcMethodName.Unsupported,
-> WcInnerRoute.UnsupportedMethodAlert
-> {
WcInnerRoute.UnsupportedMethodAlert
}
}
}
@ -64,7 +84,9 @@ internal class WcRoutingModel @Inject constructor(
merge(requestFlow, pairFlow)
.onEach { configuration ->
TangemLogger.d("WC Queue: Received configuration $configuration, waiting for queue ready")
awaitQueueReady()
TangemLogger.d("WC Queue: Queue ready, pushing configuration")
isSlotEmpty.update { false }
innerRouter.push(configuration)
}
@ -76,7 +98,12 @@ internal class WcRoutingModel @Inject constructor(
permittedAppRoute,
cardSdkProvider.sdk.uiVisibility(),
) { isSlotEmpty, permittedAppRoute, isCardSdkVisible ->
isSlotEmpty && permittedAppRoute && !isCardSdkVisible
val isReady = isSlotEmpty && permittedAppRoute && !isCardSdkVisible
TangemLogger.d(
"WC Queue: isSlotEmpty=$isSlotEmpty, permittedAppRoute=$permittedAppRoute, " +
"isCardSdkVisible=$isCardSdkVisible, ready=$isReady",
)
isReady
}.first { it }
fun onAppRouteChange(appRoute: AppRoute) {

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.features.walletconnect.connections.model.*
import com.tangem.features.walletconnect.connections.routing.WcRoutingModel
import com.tangem.features.walletconnect.transaction.model.WcAddNetworkModel
import com.tangem.features.walletconnect.transaction.model.WcGetAddressesModel
import com.tangem.features.walletconnect.transaction.model.WcSendTransactionModel
import com.tangem.features.walletconnect.transaction.model.WcSignTransactionModel
import com.tangem.features.walletconnect.transaction.model.WcSwitchNetworkModel
@ -53,6 +54,11 @@ internal interface WalletConnectModelModule {
@ClassKey(WcAddNetworkModel::class)
fun bindWcAddNetworkModel(model: WcAddNetworkModel): Model
@Binds
@IntoMap
@ClassKey(WcGetAddressesModel::class)
fun bindWcGetAddressesModel(model: WcGetAddressesModel): Model
@Binds
@IntoMap
@ClassKey(WcSwitchNetworkModel::class)

View file

@ -0,0 +1,25 @@
package com.tangem.features.walletconnect.transaction.components.addresses
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams
import com.tangem.features.walletconnect.transaction.model.WcGetAddressesModel
/**
* Component for Bitcoin getAccountAddresses WalletConnect method.
*/
internal class WcGetAddressesComponent(
appComponentContext: AppComponentContext,
params: WcTransactionModelParams,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
@Suppress("UnusedPrivateProperty")
private val model: WcGetAddressesModel = getOrCreateModel(params = params)
@Composable
override fun Content(modifier: Modifier) {
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestBlockUM
import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoItemUM
import com.tangem.utils.converter.Converter
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.toImmutableList
import org.json.JSONArray
import org.json.JSONObject
@ -58,7 +59,16 @@ internal class TransactionParamsConverter @Inject constructor() : Converter<Stri
}
}
}
loop(JSONArray(value))
try {
when (value.trimStart().firstOrNull()) {
'[' -> loop(JSONArray(value))
'{' -> loop(JSONObject(value))
else -> loop(JSONArray(value)) // default to array for backward compatibility
}
} catch (e: Exception) {
TangemLogger.withTag("Wallet Connect").e("Failed to parse transaction params: ${e.message.orEmpty()}")
}
return result
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.walletconnect.transaction.converter
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.walletconnect.model.WcBitcoinMethod
import com.tangem.domain.walletconnect.model.WcEthMethod
import com.tangem.domain.walletconnect.model.WcSolanaMethod
import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck
@ -39,6 +40,9 @@ internal class WcSendTransactionUMConverter @Inject constructor(
is WcEthMethod.SignTransaction,
is WcSolanaMethod.SignAllTransaction,
is WcSolanaMethod.SignTransaction,
is WcBitcoinMethod.SendTransfer,
is WcBitcoinMethod.SignPsbt,
is WcBitcoinMethod.SignMessage,
-> WcSendTransactionUM(
transaction = WcSendTransactionItemUM(
onDismiss = value.actions.onDismiss,

View file

@ -0,0 +1,23 @@
package com.tangem.features.walletconnect.transaction.entity.addresses
import androidx.annotation.DrawableRes
import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM
import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionAppInfoContentUM
/**
* UI model for Bitcoin getAccountAddresses WalletConnect request.
*/
internal data class WcGetAddressesUM(
val appInfo: WcTransactionAppInfoContentUM,
val networkInfo: WcNetworkInfoUM,
val addresses: List<AddressInfo>,
val isLoading: Boolean,
@DrawableRes val walletInteractionIcon: Int,
val onApprove: () -> Unit,
val onReject: () -> Unit,
) {
data class AddressInfo(
val address: String,
val intention: String?,
)
}

View file

@ -0,0 +1,46 @@
package com.tangem.features.walletconnect.transaction.model
import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.walletconnect.WcRequestUseCaseFactory
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.usecase.method.WcGetAddressesUseCase
import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams
import com.tangem.features.walletconnect.transaction.converter.WcHandleMethodErrorConverter
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Model for Bitcoin getAccountAddresses WalletConnect method.
*/
@Stable
@ModelScoped
internal class WcGetAddressesModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val useCaseFactory: WcRequestUseCaseFactory,
) : Model() {
private val params = paramsContainer.require<WcTransactionModelParams>()
init {
modelScope.launch {
val useCase = useCaseFactory.createUseCase<WcGetAddressesUseCase>(params.rawRequest)
.onLeft { showErrorDialog(it) }
.getOrNull() ?: return@launch
useCase.invoke()
.onLeft { showErrorDialog(it) }
.onRight { router.pop() }
}
}
private fun showErrorDialog(error: HandleMethodError) {
router.push(WcHandleMethodErrorConverter.convert(error))
}
}

View file

@ -35,6 +35,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import com.tangem.domain.walletconnect.WC_TAG
import javax.inject.Inject
import kotlin.properties.Delegates
@ -67,14 +69,36 @@ internal class WcSignTransactionModel @Inject constructor(
init {
modelScope.launch {
TangemLogger.withTag(WC_TAG).i("Creating use case...")
useCase = useCaseFactory.createUseCase<WcMessageSignUseCase>(params.rawRequest)
.onLeft { router.push(WcHandleMethodErrorConverter.convert(it)) }
.getOrNull() ?: return@launch
.onLeft { error ->
TangemLogger.withTag(WC_TAG).e("Failed to create use case: $error")
router.push(WcHandleMethodErrorConverter.convert(error))
}
.getOrNull() ?: run {
TangemLogger.withTag(WC_TAG).e("Use case is null, exiting")
return@launch
}
TangemLogger.withTag(WC_TAG).i("Use case created successfully")
TangemLogger.withTag(WC_TAG).i("Use case type: ${useCase.javaClass.simpleName}")
TangemLogger.withTag(WC_TAG).i("Method: ${useCase.method}")
sendSignatureReceivedAnalytics(useCase)
TangemLogger.withTag(WC_TAG).i("Invoking use case...")
useCase.invoke()
.onEach { signState ->
if (signingIsDone(signState)) return@onEach
TangemLogger.withTag(WC_TAG).i("Sign state received: ${signState.javaClass.simpleName}")
if (signingIsDone(signState)) {
TangemLogger.withTag(WC_TAG).i("Signing is DONE, not updating UI")
return@onEach
}
TangemLogger.withTag(WC_TAG).i("Converting to UI state...")
val signTransactionUM = convertToUI(useCase, signState)
TangemLogger.withTag(WC_TAG).i("UI state created, emitting...")
_uiState.emit(signTransactionUM)
}
.launchIn(this)
@ -101,7 +125,10 @@ internal class WcSignTransactionModel @Inject constructor(
portfolioName = portfolioNameDelegate.createAccountTitleUM(useCase.session),
),
)
is WcEthMethod.MessageSign, is WcSolanaMethod.SignMessage -> signTransactionUMConverter.convert(
is WcEthMethod.MessageSign,
is WcSolanaMethod.SignMessage,
is com.tangem.domain.walletconnect.model.WcBitcoinMethod.SignMessage,
-> signTransactionUMConverter.convert(
WcSignTransactionUMConverter.Input(
context = useCase,
signState = signState,
@ -110,7 +137,10 @@ internal class WcSignTransactionModel @Inject constructor(
portfolioName = portfolioNameDelegate.createAccountTitleUM(useCase.session),
),
)
else -> null
else -> {
TangemLogger.withTag(WC_TAG).e("UNSUPPORTED METHOD: ${useCase.method.javaClass.simpleName}")
null
}
}
}