diff --git a/app/src/main/java/one/mixin/android/tip/wc/WalletConnectV2.kt b/app/src/main/java/one/mixin/android/tip/wc/WalletConnectV2.kt index 65fad7b8da..3ab49afe8f 100644 --- a/app/src/main/java/one/mixin/android/tip/wc/WalletConnectV2.kt +++ b/app/src/main/java/one/mixin/android/tip/wc/WalletConnectV2.kt @@ -19,29 +19,40 @@ import one.mixin.android.tip.wc.internal.Method import one.mixin.android.tip.wc.internal.WCEthereumSignMessage import one.mixin.android.tip.wc.internal.WCEthereumTransaction import one.mixin.android.tip.wc.internal.WalletConnectException +import one.mixin.android.tip.wc.internal.WalletConnectAddresses import one.mixin.android.tip.wc.internal.WcInstruction import one.mixin.android.tip.wc.internal.WcInstructionDeserializer +import one.mixin.android.tip.wc.internal.WcBitcoinAccountAddress +import one.mixin.android.tip.wc.internal.WcBitcoinGetAccountAddresses +import one.mixin.android.tip.wc.internal.WcBitcoinSignMessage +import one.mixin.android.tip.wc.internal.WcBitcoinSignature import one.mixin.android.tip.wc.internal.WcSignature import one.mixin.android.tip.wc.internal.WcSolanaMessage import one.mixin.android.tip.wc.internal.WcSolanaTransaction +import one.mixin.android.tip.wc.internal.buildUpdatedNamespaces import one.mixin.android.tip.wc.internal.ethTransactionSerializer import one.mixin.android.tip.wc.internal.getSupportedNamespaces import one.mixin.android.tip.wc.internal.supportChainList import one.mixin.android.tip.wc.internal.evmChainList +import one.mixin.android.tip.wc.internal.isSupportedMethodForChain import one.mixin.android.util.decodeBase58 import one.mixin.android.util.encodeToBase58String +import one.mixin.android.extension.toHex import one.mixin.android.util.reportException import one.mixin.android.web3.js.Web3Signer +import org.bitcoinj.base.BitcoinNetwork +import org.bitcoinj.base.ScriptType +import org.bitcoinj.crypto.ECKey import org.sol4k.Keypair import org.sol4kt.VersionedTransactionCompat import org.web3j.crypto.Credentials import org.web3j.crypto.ECKeyPair -import org.web3j.crypto.Keys import org.web3j.crypto.RawTransaction import org.web3j.crypto.TransactionEncoder import org.web3j.utils.Numeric import timber.log.Timber import java.math.BigInteger +import java.util.Base64 import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -52,6 +63,7 @@ object WalletConnectV2 : WalletConnect() { private const val CHAIN_TYPE_POLYGON: String = "polygon" private const val CHAIN_TYPE_BSC: String = "bsc" private const val CHAIN_TYPE_SOLANA: String = "solana" + private const val CHAIN_TYPE_BTC: String = "btc" private val gson = GsonBuilder() @@ -134,14 +146,18 @@ object WalletConnectV2 : WalletConnect() { verifyContext: Wallet.Model.VerifyContext, ) { Timber.d("$TAG onSessionProposal $sessionProposal $verifyContext") - val chains = supportChainList.map { c -> c.chainId } + val supportedWalletChains = + getSupportedNamespaces(currentWalletConnectAddresses()) + .values + .flatMap { it.chains ?: emptyList() } + .toSet() val namespaces = (sessionProposal.requiredNamespaces.values + sessionProposal.optionalNamespaces.values) .filter { proposal -> proposal.chains != null } val hasSupportChain = namespaces.any { proposal -> proposal.chains!!.any { chain -> - chains.contains(chain) + supportedWalletChains.contains(chain) } } @@ -159,12 +175,13 @@ object WalletConnectV2 : WalletConnect() { return } val requireChain = - supportChainList.firstOrNull { - (namespace).chains?.contains(it.chainId) == true + supportChainList.firstOrNull { chain -> + namespace.chains?.any { chainId -> chain.supportsWalletConnectChainId(chainId) } == true } val chainType = when { requireChain is Chain.Solana -> CHAIN_TYPE_SOLANA + requireChain is Chain.Bitcoin -> CHAIN_TYPE_BTC requireChain is Chain.BinanceSmartChain -> CHAIN_TYPE_BSC requireChain is Chain.Polygon -> CHAIN_TYPE_POLYGON else -> CHAIN_TYPE_ETH @@ -176,7 +193,7 @@ object WalletConnectV2 : WalletConnect() { val notSupportChainIds = namespaces.flatMap { proposal -> proposal.chains!!.filter { chain -> - !chains.contains(chain) + !supportedWalletChains.contains(chain) } }.toSet().joinToString() RxBus.publish( @@ -229,17 +246,7 @@ object WalletConnectV2 : WalletConnect() { } } - private fun flattenCollections(collection: List?>): List { - val result = mutableListOf() - for (innerCollection in collection) { - if (innerCollection == null) continue - result.addAll(innerCollection) - } - return result - } - fun approveSession( - priv: ByteArray, topic: String, ) { val sessionProposal = getSessionProposal(topic) @@ -247,27 +254,11 @@ object WalletConnectV2 : WalletConnect() { Timber.e("$TAG approveSession sessionProposal is null") return } - val namespaces: Collection = flattenCollections((sessionProposal.requiredNamespaces + sessionProposal.optionalNamespaces).values.map { it.chains }) - val chain = - if (namespaces.isEmpty()) { - supportChainList.firstOrNull() - } else { - supportChainList.find { - it.chainId in namespaces - } - } - if (chain == null) { - Timber.e("$TAG approveSession sessionProposal chain is null") + val supportedNamespaces = getSupportedNamespaces(currentWalletConnectAddresses()) + if (supportedNamespaces.isEmpty()) { + Timber.e("$TAG approveSession wallet has no supported address") return } - val address = - if (chain == Chain.Solana) { - Keypair.fromSecretKey(priv).publicKey.toBase58() - } else { - val pub = ECKeyPair.create(priv).publicKey - Keys.toChecksumAddress(Keys.getAddress(pub)) - } - val supportedNamespaces = getSupportedNamespaces(chain, address) Timber.e("$TAG supportedNamespaces $supportedNamespaces") val sessionNamespaces = WalletKit.generateApprovedNamespaces(sessionProposal, supportedNamespaces) Timber.d("$TAG approveSession $sessionNamespaces") @@ -315,6 +306,10 @@ object WalletConnectV2 : WalletConnect() { localAddress: String, request: Wallet.Model.SessionRequest, ): WCSignData.V2SignData<*>? { + if (!isSupportedMethodForChain(request.request.method, request.chainId)) { + Timber.e("$TAG ${request.request.method} parseSessionRequest not supported method ${request.request.method} for chain ${request.chainId}") + return null + } val signData = when (request.request.method) { Method.ETHSign.name -> { @@ -380,6 +375,17 @@ object WalletConnectV2 : WalletConnect() { val message = gson.fromJson(request.request.params) WCSignData.V2SignData(request.request.id, message, request) } + Method.BtcGetAccountAddresses.name -> { + val message = gson.fromJson(request.request.params) + validateBitcoinAccount(message.account, localAddress) + WCSignData.V2SignData(request.request.id, message, request) + } + Method.BtcSignMessage.name -> { + val message = gson.fromJson(request.request.params) + validateBitcoinAccount(message.account, localAddress) + message.address?.let { validateBitcoinAccount(it, localAddress) } + WCSignData.V2SignData(request.request.id, message, request) + } else -> { Timber.e("$TAG ${request.request.method} parseSessionRequest not supported method ${request.request.method}") null @@ -432,6 +438,10 @@ object WalletConnectV2 : WalletConnect() { val wcSig = WcSignature(signMessage.pubkey, sig) approveRequestInternal(gson.toJson(wcSig), sessionRequest) return null + } else if (signMessage is WcBitcoinGetAccountAddresses) { + approveBitcoinAddresses(signMessage, sessionRequest) + } else if (signMessage is WcBitcoinSignMessage) { + approveBitcoinMessage(priv, signMessage, sessionRequest) } return null } @@ -510,33 +520,18 @@ object WalletConnectV2 : WalletConnect() { } } - fun switchAccount(address:String) { + fun switchAccount(addresses: WalletConnectAddresses = currentWalletConnectAddresses()) { val sessions = getListOfActiveSessions() if (sessions.isEmpty()) { Timber.e("$TAG switchAccount session not found for topic") return } sessions.forEach { session -> - val newNamespaces = session.namespaces.mapValues { (_, ns) -> - val chainId = ns.chains?.firstOrNull() - if (chainId == null) { - Timber.w("$TAG switchAccount: namespace has no chains, skipping update for it") - return@mapValues ns - } - val chain = supportChainList.find { it.chainId == chainId } - if (chain == null) { - Timber.w("$TAG switchAccount: unsupported chainId $chainId, skipping update for it") - return@mapValues ns - } - - val newAccount = "$chainId:$address" - - Wallet.Model.Namespace.Session( - chains = ns.chains, - accounts = listOf(newAccount), - methods = ns.methods, - events = ns.events, - ) + val newNamespaces = buildUpdatedNamespaces(session.namespaces, addresses) + if (newNamespaces == null) { + Timber.w("$TAG switchAccount: current wallet does not have every connected chain address, disconnecting ${session.topic}") + disconnect(session.topic) + return@forEach } val updateParams = Wallet.Params.SessionUpdate( @@ -644,6 +639,46 @@ object WalletConnectV2 : WalletConnect() { return hexMessage } + private fun approveBitcoinAddresses( + request: WcBitcoinGetAccountAddresses, + sessionRequest: Wallet.Model.SessionRequest, + ) { + val address = request.account + val result = + listOf( + WcBitcoinAccountAddress( + address = address, + intention = request.intentions?.firstOrNull() ?: "payment", + ), + ) + approveRequestInternal(gson.toJson(result), sessionRequest) + } + + private fun approveBitcoinMessage( + priv: ByteArray, + request: WcBitcoinSignMessage, + sessionRequest: Wallet.Model.SessionRequest, + ) { + if (request.protocol != null && request.protocol != "ecdsa") { + throw IllegalArgumentException("Unsupported Bitcoin signature protocol ${request.protocol}") + } + val key = ECKey.fromPrivate(priv, true) + val address = key.toAddress(ScriptType.P2WPKH, BitcoinNetwork.MAINNET).toString() + val requestedAddress = request.address ?: request.account + validateBitcoinAccount(requestedAddress, address) + val signature = Base64.getDecoder().decode(key.signMessage(request.message, ScriptType.P2WPKH)).toHex() + approveRequestInternal(gson.toJson(WcBitcoinSignature(address, signature)), sessionRequest) + } + + private fun validateBitcoinAccount( + requested: String, + localAddress: String, + ) { + if (localAddress.isNotBlank() && requested != localAddress) { + throw IllegalArgumentException("Address unequal") + } + } + fun approveSolanaTransaction( signature: String, sessionRequest: Wallet.Model.SessionRequest, @@ -707,4 +742,16 @@ object WalletConnectV2 : WalletConnect() { fun Wallet.Model.SessionProposal.getNamespaceProposal(): Wallet.Model.Namespace.Proposal? = this.requiredNamespaces["solana"] ?: this.optionalNamespaces["solana"] ?: this.requiredNamespaces.values.firstOrNull() ?: this.optionalNamespaces.values.firstOrNull() + + fun Wallet.Model.SessionProposal.getProposalChainIds(): Set = + (this.requiredNamespaces.values + this.optionalNamespaces.values) + .flatMap { it.chains ?: emptyList() } + .toSet() + + private fun currentWalletConnectAddresses(): WalletConnectAddresses = + WalletConnectAddresses( + evm = Web3Signer.evmAddress, + solana = Web3Signer.solanaAddress, + bitcoin = Web3Signer.btcAddress, + ) } diff --git a/app/src/main/java/one/mixin/android/tip/wc/internal/Chain.kt b/app/src/main/java/one/mixin/android/tip/wc/internal/Chain.kt index b8d402dca8..96be473c9e 100644 --- a/app/src/main/java/one/mixin/android/tip/wc/internal/Chain.kt +++ b/app/src/main/java/one/mixin/android/tip/wc/internal/Chain.kt @@ -16,6 +16,7 @@ sealed class Chain( val name: String, val symbol: String, private val rpcServers: List, + private val walletConnectChainIdAliases: List = emptyList(), ) { object Ethereum : Chain(ETHEREUM_CHAIN_ID, "eip155", "1", "0x1", "Ethereum", "ETH", listOf("https://eth.llamarpc.com")) @@ -33,20 +34,34 @@ sealed class Chain( object HyperEVM : Chain(Constants.ChainId.HyperEVM, "eip155", "999", "0x3e7", "HyperEVM", "HYPE", listOf("https://rpc.hyperliquid.xyz/evm")) - object Solana : Chain(SOLANA_CHAIN_ID, "solana", "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ", "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ", "Solana", "SOL", listOf("https://api.mainnet-beta.solana.com")) + object Solana : Chain( + SOLANA_CHAIN_ID, + "solana", + "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "Solana", + "SOL", + listOf("https://api.mainnet-beta.solana.com"), + listOf("solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ"), + ) - object Bitcoin : Chain(BITCOIN_CHAIN_ID, "BTC", "", "", "Bitcoin", "BTC", listOf("")) + object Bitcoin : Chain(BITCOIN_CHAIN_ID, "bip122", "000000000019d6689c085ae165831e93", "000000000019d6689c085ae165831e93", "Bitcoin", "BTC", listOf("")) val chainId: String get() { return "$chainNamespace:$chainReference" } + val walletConnectChainIds: List + get() = listOf(chainId) + walletConnectChainIdAliases + val rpcUrl: String get() { return MixinApplication.appContext.defaultSharedPreferences.getString(chainId, null) ?: rpcServers.first() } + fun supportsWalletConnectChainId(chainId: String): Boolean = chainId in walletConnectChainIds + fun getWeb3ChainId(): String = // Blast -> Constants.ChainId. when (this) { @@ -58,63 +73,80 @@ sealed class Chain( Base -> Constants.ChainId.Base Avalanche -> Constants.ChainId.Avalanche HyperEVM -> Constants.ChainId.HyperEVM - else -> Constants.ChainId.Solana + Solana -> Constants.ChainId.Solana + Bitcoin -> BITCOIN_CHAIN_ID } } // Chain.Blast -internal val supportChainList = listOf(Chain.Solana, Chain.Ethereum, Chain.Base, Chain.BinanceSmartChain, Chain.Polygon, Chain.Optimism, Chain.Arbitrum, Chain.Avalanche, Chain.HyperEVM) +internal val supportChainList = listOf(Chain.Solana, Chain.Bitcoin, Chain.Ethereum, Chain.Base, Chain.BinanceSmartChain, Chain.Polygon, Chain.Optimism, Chain.Arbitrum, Chain.Avalanche, Chain.HyperEVM) internal val evmChainList = listOf(Chain.Ethereum, Chain.Base, Chain.BinanceSmartChain, Chain.Polygon, Chain.Optimism, Chain.Arbitrum, Chain.Avalanche, Chain.HyperEVM) +data class WalletConnectAddresses( + val evm: String, + val solana: String, + val bitcoin: String, +) + +internal fun WalletConnectAddresses.accountFor(chain: Chain): String = + when (chain) { + Chain.Solana -> solana + Chain.Bitcoin -> bitcoin + else -> evm + } + +internal fun WalletConnectAddresses.accountForChainId(chainId: String): String? = + getChainByChainId(chainId)?.let { chain -> + accountFor(chain).takeIf { it.isNotBlank() } + } + +internal fun buildUpdatedNamespaces( + namespaces: Map, + addresses: WalletConnectAddresses, +): Map? = + namespaces.mapValues { (_, namespace) -> + val chains = namespace.chains + if (chains.isNullOrEmpty()) return@mapValues namespace + + val accounts = + chains.map { chainId -> + val address = addresses.accountForChainId(chainId) ?: return null + "$chainId:$address" + } + + Wallet.Model.Namespace.Session( + chains = chains, + accounts = accounts, + methods = namespace.methods, + events = namespace.events, + ) + } + internal fun String.getChain(): Chain? { - return when (this) { - Chain.Ethereum.chainReference -> Chain.Ethereum - Chain.Base.chainReference -> Chain.Base - Chain.Arbitrum.chainReference -> Chain.Arbitrum - Chain.Optimism.chainReference -> Chain.Optimism - Chain.Avalanche.chainReference -> Chain.Avalanche - Chain.BinanceSmartChain.chainReference -> Chain.BinanceSmartChain - Chain.Polygon.chainReference -> Chain.Polygon - Chain.HyperEVM.chainReference -> Chain.HyperEVM - Chain.Solana.chainId -> Chain.Solana - else -> null + return supportChainList.firstOrNull { chain -> + this == chain.chainReference || chain.supportsWalletConnectChainId(this) } } internal fun getChainByChainId(chainId: String?): Chain? { if (chainId == null) return null - return when (chainId) { - Chain.Ethereum.chainId -> Chain.Ethereum - Chain.Base.chainId -> Chain.Base - Chain.Arbitrum.chainId -> Chain.Arbitrum - Chain.Optimism.chainId -> Chain.Optimism - Chain.Avalanche.chainId -> Chain.Avalanche - Chain.BinanceSmartChain.chainId -> Chain.BinanceSmartChain - Chain.Polygon.chainId -> Chain.Polygon - Chain.HyperEVM.chainId -> Chain.HyperEVM - Chain.Solana.chainId -> Chain.Solana - else -> null + return supportChainList.firstOrNull { chain -> + chain.supportsWalletConnectChainId(chainId) } } -fun getSupportedNamespaces( - chain: Chain, - address: String, -): Map { - return when { - chain == Chain.Solana -> { - getSolanaNamespaces(address) +fun getSupportedNamespaces(addresses: WalletConnectAddresses): Map = + buildMap { + if (addresses.evm.isNotBlank()) { + putAll(getEvmNamespaces(addresses.evm)) } - - evmChainList.contains(chain) -> { - getEvmNamespaces(address) + if (addresses.solana.isNotBlank()) { + putAll(getSolanaNamespaces(addresses.solana)) } - - else -> { - throw IllegalArgumentException("Not supported chain ${chain.name}") + if (addresses.bitcoin.isNotBlank()) { + putAll(getBitcoinNamespaces(addresses.bitcoin)) } } -} private fun getEvmNamespaces(address: String): Map { val chainIds = evmChainList.map { chain -> chain.chainId } @@ -130,14 +162,27 @@ private fun getEvmNamespaces(address: String): Map { + return mapOf( + "bip122" to + Wallet.Model.Namespace.Session( + chains = listOf(Chain.Bitcoin.chainId), + methods = bitcoinSupportedMethods, + events = listOf("bip122_addressesChanged"), + accounts = listOf("${Chain.Bitcoin.chainId}:$address"), + ), + ) +} + private fun getSolanaNamespaces(address: String): Map { + val chainIds = Chain.Solana.walletConnectChainIds return mapOf( "solana" to Wallet.Model.Namespace.Session( - chains = listOf("solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ"), - methods = solanaSupporedMethods, + chains = chainIds, + methods = solanaSupportedMethods, events = listOf(""), - accounts = listOf("solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ:$address"), + accounts = chainIds.map { chainId -> "$chainId:$address" }, ), ) } diff --git a/app/src/main/java/one/mixin/android/tip/wc/internal/Method.kt b/app/src/main/java/one/mixin/android/tip/wc/internal/Method.kt index a787eb0324..a71fea5a36 100644 --- a/app/src/main/java/one/mixin/android/tip/wc/internal/Method.kt +++ b/app/src/main/java/one/mixin/android/tip/wc/internal/Method.kt @@ -16,6 +16,10 @@ sealed class Method(val name: String) { object SolanaSignTransaction : Method("solana_signTransaction") object SolanaSignMessage : Method("solana_signMessage") + + object BtcGetAccountAddresses : Method("getAccountAddresses") + + object BtcSignMessage : Method("signMessage") } val evmSupportedMethods = @@ -26,10 +30,27 @@ val evmSupportedMethods = Method.ETHSignTypedDataV4.name, Method.ETHSignTransaction.name, Method.ETHSendTransaction.name, - Method.SolanaSignMessage.name, ) -val solanaSupporedMethods = +val solanaSupportedMethods = listOf( Method.SolanaSignMessage.name, Method.SolanaSignTransaction.name, ) +val bitcoinSupportedMethods = + listOf( + Method.BtcGetAccountAddresses.name, + Method.BtcSignMessage.name, + ) + +internal fun isSupportedMethodForChain( + method: String, + chainId: String?, +): Boolean { + val chain = getChainByChainId(chainId) + return when { + chain == Chain.Bitcoin -> bitcoinSupportedMethods.contains(method) + chain == Chain.Solana -> solanaSupportedMethods.contains(method) + chain != null && evmChainList.contains(chain) -> evmSupportedMethods.contains(method) + else -> false + } +} diff --git a/app/src/main/java/one/mixin/android/tip/wc/internal/WalletConnectAccountText.kt b/app/src/main/java/one/mixin/android/tip/wc/internal/WalletConnectAccountText.kt new file mode 100644 index 0000000000..385d5357ab --- /dev/null +++ b/app/src/main/java/one/mixin/android/tip/wc/internal/WalletConnectAccountText.kt @@ -0,0 +1,17 @@ +package one.mixin.android.tip.wc.internal + +internal fun formatProposalAccountText( + chainIds: Set, + addresses: WalletConnectAddresses, +): String = + buildList { + if (chainIds.any { it.startsWith("eip155:") } && addresses.evm.isNotBlank()) { + add("EVM: ${addresses.evm}") + } + if (chainIds.any { Chain.Solana.supportsWalletConnectChainId(it) } && addresses.solana.isNotBlank()) { + add("${Chain.Solana.name}: ${addresses.solana}") + } + if (Chain.Bitcoin.chainId in chainIds && addresses.bitcoin.isNotBlank()) { + add("${Chain.Bitcoin.name}: ${addresses.bitcoin}") + } + }.joinToString("\n") diff --git a/app/src/main/java/one/mixin/android/tip/wc/internal/WcBitcoin.kt b/app/src/main/java/one/mixin/android/tip/wc/internal/WcBitcoin.kt new file mode 100644 index 0000000000..294dd08134 --- /dev/null +++ b/app/src/main/java/one/mixin/android/tip/wc/internal/WcBitcoin.kt @@ -0,0 +1,26 @@ +package one.mixin.android.tip.wc.internal + +data class WcBitcoinGetAccountAddresses( + val account: String, + val intentions: List? = null, +) + +data class WcBitcoinSignMessage( + val account: String, + val message: String, + val address: String? = null, + val protocol: String? = null, +) + +data class WcBitcoinAccountAddress( + val address: String, + val publicKey: String? = null, + val path: String? = null, + val intention: String = "payment", +) + +data class WcBitcoinSignature( + val address: String, + val signature: String, + val messageHash: String? = null, +) diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt index 85e6423d2e..8cceb77be4 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt @@ -44,12 +44,15 @@ import one.mixin.android.tip.wc.WalletConnect import one.mixin.android.tip.wc.WalletConnect.RequestType import one.mixin.android.tip.wc.WalletConnectTIP import one.mixin.android.tip.wc.WalletConnectV2 +import one.mixin.android.tip.wc.WalletConnectV2.getProposalChainIds import one.mixin.android.tip.wc.WalletConnectV2.getNamespaceProposal import one.mixin.android.tip.wc.internal.Chain import one.mixin.android.tip.wc.internal.TipGas import one.mixin.android.tip.wc.internal.WCEthereumTransaction +import one.mixin.android.tip.wc.internal.WalletConnectAddresses import one.mixin.android.tip.wc.internal.WalletConnectException import one.mixin.android.tip.wc.internal.buildTipGas +import one.mixin.android.tip.wc.internal.formatProposalAccountText import one.mixin.android.tip.wc.internal.getChain import one.mixin.android.tip.wc.internal.getChainByChainId import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment @@ -73,7 +76,6 @@ import one.mixin.android.vo.safe.Token import one.mixin.android.web3.Rpc import one.mixin.android.web3.js.Web3Signer import one.mixin.android.web3.js.throwIfAnyMaliciousInstruction -import org.sol4k.VersionedTransaction import org.sol4k.exception.RpcException import org.sol4kt.VersionedTransactionCompat import timber.log.Timber @@ -275,8 +277,8 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag RequestType.SessionProposal -> { sessionProposal = viewModel.getV2SessionProposal(topic)?.apply { - this.getNamespaceProposal()?.chains?.firstOrNull { - c -> c.getChain() != null + this.getNamespaceProposal()?.chains?.firstOrNull { c -> + c.getChain() != null }?.getChain()?.let { chain = it } } } @@ -290,10 +292,10 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } account = - if (chain != Chain.Solana) { - Web3Signer.evmAddress + if (requestType == RequestType.SessionProposal) { + sessionProposal?.let { proposalAccountText(it) } ?: accountFor(chain) } else { - Web3Signer.solanaAddress + accountFor(chain) } if (requestType != RequestType.SessionRequest) return@launch @@ -337,10 +339,6 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag val tx = signData.signMessage if (tx !is WCEthereumTransaction) return val assetId = chain.getWeb3ChainId() - if (assetId == null) { - Timber.d("$TAG refreshEstimatedGasAndAsset assetId not support") - return - } tickerFlow(15.seconds) .onEach { @@ -366,7 +364,7 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag tipGas = buildTipGas(chain.chainId, r.data!!) } if (tipGas != null) { - (signData as? WalletConnect.WCSignData.V2SignData)?.tipGas = tipGas + signData.tipGas = tipGas } } catch (e: Exception) { Timber.e(e) @@ -385,8 +383,13 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag if (onPinCompleteAction != null) { onPinCompleteAction?.invoke(pin) } else { - val privateKey = viewModel.getWeb3Priv(requireContext(), pin, chain.assetId) - approveWithPriv(privateKey) + if (version == WalletConnect.Version.V2 && requestType == RequestType.SessionProposal) { + viewModel.verifyPin(requireContext(), pin) + approveWithPriv(ByteArray(0)) + } else { + val privateKey = viewModel.getWeb3Priv(requireContext(), pin, chain.assetId) + approveWithPriv(privateKey) + } } } if (error == null) { @@ -436,7 +439,7 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag when (requestType) { RequestType.Connect -> {} RequestType.SessionProposal -> { - WalletConnectV2.approveSession(priv, topic) + WalletConnectV2.approveSession(topic) } RequestType.SessionRequest -> { val signData = this.signData ?: return "SignData is null" @@ -509,9 +512,27 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag step = Step.Error } + private fun accountFor(chain: Chain): String = + when (chain) { + Chain.Solana -> Web3Signer.solanaAddress + Chain.Bitcoin -> Web3Signer.btcAddress + else -> Web3Signer.evmAddress + } + + private fun proposalAccountText(sessionProposal: Wallet.Model.SessionProposal): String { + return formatProposalAccountText( + sessionProposal.getProposalChainIds(), + WalletConnectAddresses( + evm = Web3Signer.evmAddress, + solana = Web3Signer.solanaAddress, + bitcoin = Web3Signer.btcAddress, + ), + ) + } + private fun isSignEvmTransaction() = signData != null && signData?.signMessage is WCEthereumTransaction - private fun isSignSolanaTransaction() = signData != null && signData?.signMessage is VersionedTransaction + private fun isSignSolanaTransaction() = signData != null && signData?.signMessage is VersionedTransactionCompat private val bottomSheetBehaviorCallback = object : BottomSheetBehavior.BottomSheetCallback() { diff --git a/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetViewModel.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetViewModel.kt index f91f1a8e42..10c60ea850 100644 --- a/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetViewModel.kt @@ -60,6 +60,14 @@ class WalletConnectBottomSheetViewModel return requireNotNull(CryptoWalletHelper.getWeb3PrivateKey(context, spendKey, chainId)) } + suspend fun verifyPin( + context: Context, + pin: String, + ) { + val result = tip.getOrRecoverTipPriv(context, pin) + result.getOrThrow() + } + suspend fun refreshAsset(assetId: String) = assetRepo.refreshAsset(assetId) suspend fun sendTransaction( diff --git a/app/src/main/java/one/mixin/android/web3/js/Web3Signer.kt b/app/src/main/java/one/mixin/android/web3/js/Web3Signer.kt index 364868248d..72e9d71b7f 100644 --- a/app/src/main/java/one/mixin/android/web3/js/Web3Signer.kt +++ b/app/src/main/java/one/mixin/android/web3/js/Web3Signer.kt @@ -21,6 +21,7 @@ import one.mixin.android.tip.wc.WalletConnectV2 import one.mixin.android.tip.wc.internal.Chain import one.mixin.android.tip.wc.internal.TipGas import one.mixin.android.tip.wc.internal.WCEthereumTransaction +import one.mixin.android.tip.wc.internal.WalletConnectAddresses import one.mixin.android.tip.wc.internal.evmChainList import one.mixin.android.util.GsonHelper import one.mixin.android.util.decodeBase58 @@ -221,11 +222,7 @@ object Web3Signer { } if (WalletConnect.isEnabled()) { - if (currentChain.assetId == SOLANA_CHAIN_ID) { - WalletConnectV2.switchAccount(solanaAddress) - } else { - WalletConnectV2.switchAccount(evmAddress) - } + WalletConnectV2.switchAccount(WalletConnectAddresses(evmAddress, solanaAddress, btcAddress)) } } diff --git a/app/src/test/java/one/mixin/android/tip/wc/internal/WalletConnectNamespaceTest.kt b/app/src/test/java/one/mixin/android/tip/wc/internal/WalletConnectNamespaceTest.kt new file mode 100644 index 0000000000..908db4a9c5 --- /dev/null +++ b/app/src/test/java/one/mixin/android/tip/wc/internal/WalletConnectNamespaceTest.kt @@ -0,0 +1,198 @@ +package one.mixin.android.tip.wc.internal + +import com.reown.walletkit.client.Wallet +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WalletConnectNamespaceTest { + private val legacySolanaChainId = "solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ" + + @Test + fun solanaChainIdUsesMainnetCaip2Reference() { + assertEquals("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", Chain.Solana.chainId) + assertEquals(Chain.Solana, getChainByChainId("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")) + } + + @Test + fun legacySolanaChainIdResolvesToSolana() { + val addresses = WalletConnectAddresses( + evm = "0x1111111111111111111111111111111111111111", + solana = "So11111111111111111111111111111111111111112", + bitcoin = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", + ) + + assertEquals(Chain.Solana, getChainByChainId(legacySolanaChainId)) + assertEquals(Chain.Solana, legacySolanaChainId.getChain()) + assertEquals(addresses.solana, addresses.accountForChainId(legacySolanaChainId)) + assertTrue(isSupportedMethodForChain(Method.SolanaSignMessage.name, legacySolanaChainId)) + } + + @Test + fun supportedNamespacesIncludeEveryAvailableWalletAddress() { + val evmAddress = "0x1111111111111111111111111111111111111111" + val solanaAddress = "So11111111111111111111111111111111111111112" + val bitcoinAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + + val namespaces = getSupportedNamespaces( + WalletConnectAddresses( + evm = evmAddress, + solana = solanaAddress, + bitcoin = bitcoinAddress, + ), + ) + + assertEquals(setOf("eip155", "solana", "bip122"), namespaces.keys) + assertEquals(evmChainList.map { it.chainId }, namespaces.getValue("eip155").chains) + assertTrue(namespaces.getValue("eip155").accounts.contains("${Chain.Ethereum.chainId}:$evmAddress")) + assertFalse(namespaces.getValue("eip155").methods.contains(Method.SolanaSignMessage.name)) + assertEquals(listOf(Chain.Solana.chainId, legacySolanaChainId), namespaces.getValue("solana").chains) + assertEquals(listOf("${Chain.Solana.chainId}:$solanaAddress", "$legacySolanaChainId:$solanaAddress"), namespaces.getValue("solana").accounts) + assertEquals(listOf(Chain.Bitcoin.chainId), namespaces.getValue("bip122").chains) + assertEquals(listOf("${Chain.Bitcoin.chainId}:$bitcoinAddress"), namespaces.getValue("bip122").accounts) + assertTrue(namespaces.getValue("bip122").methods.contains(Method.BtcGetAccountAddresses.name)) + } + + @Test + fun supportedNamespacesSkipBlankAddresses() { + val namespaces = getSupportedNamespaces( + WalletConnectAddresses( + evm = "0x1111111111111111111111111111111111111111", + solana = "", + bitcoin = "", + ), + ) + + assertEquals(setOf("eip155"), namespaces.keys) + } + + @Test + fun walletConnectAddressesSelectAccountByChainId() { + val addresses = WalletConnectAddresses( + evm = "0x1111111111111111111111111111111111111111", + solana = "So11111111111111111111111111111111111111112", + bitcoin = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", + ) + + assertEquals(addresses.evm, addresses.accountForChainId(Chain.Base.chainId)) + assertEquals(addresses.solana, addresses.accountForChainId(Chain.Solana.chainId)) + assertEquals(addresses.bitcoin, addresses.accountForChainId(Chain.Bitcoin.chainId)) + } + + @Test + fun walletConnectMethodsAreScopedToTheirChainNamespace() { + assertTrue(isSupportedMethodForChain(Method.BtcSignMessage.name, Chain.Bitcoin.chainId)) + assertFalse(isSupportedMethodForChain(Method.BtcSignMessage.name, Chain.Ethereum.chainId)) + assertFalse(isSupportedMethodForChain(Method.BtcSignMessage.name, Chain.Solana.chainId)) + assertTrue(isSupportedMethodForChain(Method.ETHSendTransaction.name, Chain.Base.chainId)) + assertFalse(isSupportedMethodForChain(Method.ETHSendTransaction.name, Chain.Bitcoin.chainId)) + } + + @Test + fun proposalAccountTextDoesNotFallBackToEvmWhenProposalHasNoSupportedAccount() { + val addresses = WalletConnectAddresses( + evm = "0x1111111111111111111111111111111111111111", + solana = "", + bitcoin = "", + ) + + assertEquals("", formatProposalAccountText(setOf(Chain.Solana.chainId), addresses)) + } + + @Test + fun proposalAccountTextAcceptsLegacySolanaChainId() { + val addresses = WalletConnectAddresses( + evm = "0x1111111111111111111111111111111111111111", + solana = "So11111111111111111111111111111111111111112", + bitcoin = "", + ) + + assertEquals("${Chain.Solana.name}: ${addresses.solana}", formatProposalAccountText(setOf(legacySolanaChainId), addresses)) + } + + @Test + fun sessionNamespaceUpdateReturnsNullWhenWalletNoLongerHasAConnectedChainAddress() { + val namespaces = + mapOf( + "eip155" to + Wallet.Model.Namespace.Session( + chains = listOf(Chain.Ethereum.chainId), + accounts = listOf("${Chain.Ethereum.chainId}:0x1111111111111111111111111111111111111111"), + methods = evmSupportedMethods, + events = emptyList(), + ), + "bip122" to + Wallet.Model.Namespace.Session( + chains = listOf(Chain.Bitcoin.chainId), + accounts = listOf("${Chain.Bitcoin.chainId}:bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"), + methods = bitcoinSupportedMethods, + events = emptyList(), + ), + ) + + val updated = + buildUpdatedNamespaces( + namespaces, + WalletConnectAddresses( + evm = "0x2222222222222222222222222222222222222222", + solana = "So11111111111111111111111111111111111111112", + bitcoin = "", + ), + ) + + assertNull(updated) + } + + @Test + fun sessionNamespaceUpdateReplacesEveryConnectedChainAccount() { + val evmAddress = "0x2222222222222222222222222222222222222222" + val solanaAddress = "So11111111111111111111111111111111111111112" + val bitcoinAddress = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu" + val namespaces = + mapOf( + "eip155" to + Wallet.Model.Namespace.Session( + chains = listOf(Chain.Ethereum.chainId, Chain.Base.chainId), + accounts = listOf( + "${Chain.Ethereum.chainId}:0x1111111111111111111111111111111111111111", + "${Chain.Base.chainId}:0x1111111111111111111111111111111111111111", + ), + methods = evmSupportedMethods, + events = emptyList(), + ), + "solana" to + Wallet.Model.Namespace.Session( + chains = listOf(legacySolanaChainId), + accounts = listOf("$legacySolanaChainId:OldSolanaAddress"), + methods = solanaSupportedMethods, + events = emptyList(), + ), + "bip122" to + Wallet.Model.Namespace.Session( + chains = listOf(Chain.Bitcoin.chainId), + accounts = listOf("${Chain.Bitcoin.chainId}:OldBitcoinAddress"), + methods = bitcoinSupportedMethods, + events = emptyList(), + ), + ) + + val updated = + buildUpdatedNamespaces( + namespaces, + WalletConnectAddresses( + evm = evmAddress, + solana = solanaAddress, + bitcoin = bitcoinAddress, + ), + ) + + assertEquals( + listOf("${Chain.Ethereum.chainId}:$evmAddress", "${Chain.Base.chainId}:$evmAddress"), + updated?.getValue("eip155")?.accounts, + ) + assertEquals(listOf("$legacySolanaChainId:$solanaAddress"), updated?.getValue("solana")?.accounts) + assertEquals(listOf("${Chain.Bitcoin.chainId}:$bitcoinAddress"), updated?.getValue("bip122")?.accounts) + } +}