From 57651620619da076375c8e5e58aef483e1c7723f Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:42:01 +0900 Subject: [PATCH 1/5] Support WalletConnect multi-chain namespaces --- .../mixin/android/tip/wc/WalletConnectV2.kt | 140 ++++++++++++------ .../mixin/android/tip/wc/internal/Chain.kt | 56 ++++++- .../mixin/android/tip/wc/internal/Method.kt | 10 +- .../android/tip/wc/internal/WcBitcoin.kt | 26 ++++ .../WalletConnectBottomSheetDialogFragment.kt | 54 +++++-- .../wc/WalletConnectBottomSheetViewModel.kt | 8 + .../one/mixin/android/web3/js/Web3Signer.kt | 7 +- .../wc/internal/WalletConnectNamespaceTest.kt | 59 ++++++++ 8 files changed, 293 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/tip/wc/internal/WcBitcoin.kt create mode 100644 app/src/test/java/one/mixin/android/tip/wc/internal/WalletConnectNamespaceTest.kt 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..4e9a924b93 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,39 @@ 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.accountForChainId 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.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 +62,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 +145,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) } } @@ -165,6 +180,7 @@ object WalletConnectV2 : WalletConnect() { 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 +192,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 +245,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 +253,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") @@ -380,6 +370,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 +433,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,7 +515,7 @@ 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") @@ -518,22 +523,19 @@ object WalletConnectV2 : WalletConnect() { } sessions.forEach { session -> val newNamespaces = session.namespaces.mapValues { (_, ns) -> - val chainId = ns.chains?.firstOrNull() - if (chainId == null) { + val chains = ns.chains + if (chains.isNullOrEmpty()) { 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 newAccounts = chains.mapNotNull { chainId -> + addresses.accountForChainId(chainId)?.let { address -> "$chainId:$address" } } - - val newAccount = "$chainId:$address" + if (newAccounts.isEmpty()) return@mapValues ns Wallet.Model.Namespace.Session( chains = ns.chains, - accounts = listOf(newAccount), + accounts = newAccounts, methods = ns.methods, events = ns.events, ) @@ -644,6 +646,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 +749,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..0bbc857ecb 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 @@ -35,7 +35,7 @@ sealed class Chain( object Solana : Chain(SOLANA_CHAIN_ID, "solana", "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ", "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ", "Solana", "SOL", listOf("https://api.mainnet-beta.solana.com")) - 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() { @@ -58,13 +58,32 @@ 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 String.getChain(): Chain? { return when (this) { Chain.Ethereum.chainReference -> Chain.Ethereum @@ -76,6 +95,7 @@ internal fun String.getChain(): Chain? { Chain.Polygon.chainReference -> Chain.Polygon Chain.HyperEVM.chainReference -> Chain.HyperEVM Chain.Solana.chainId -> Chain.Solana + Chain.Bitcoin.chainId -> Chain.Bitcoin else -> null } } @@ -93,10 +113,24 @@ internal fun getChainByChainId(chainId: String?): Chain? { Chain.Polygon.chainId -> Chain.Polygon Chain.HyperEVM.chainId -> Chain.HyperEVM Chain.Solana.chainId -> Chain.Solana + Chain.Bitcoin.chainId -> Chain.Bitcoin else -> null } } +fun getSupportedNamespaces(addresses: WalletConnectAddresses): Map = + buildMap { + if (addresses.evm.isNotBlank()) { + putAll(getEvmNamespaces(addresses.evm)) + } + if (addresses.solana.isNotBlank()) { + putAll(getSolanaNamespaces(addresses.solana)) + } + if (addresses.bitcoin.isNotBlank()) { + putAll(getBitcoinNamespaces(addresses.bitcoin)) + } + } + fun getSupportedNamespaces( chain: Chain, address: String, @@ -110,6 +144,10 @@ fun getSupportedNamespaces( getEvmNamespaces(address) } + chain == Chain.Bitcoin -> { + getBitcoinNamespaces(address) + } + else -> { throw IllegalArgumentException("Not supported chain ${chain.name}") } @@ -130,6 +168,18 @@ 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 { return mapOf( "solana" to 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..d437f3324a 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,14 @@ val evmSupportedMethods = Method.ETHSignTypedDataV4.name, Method.ETHSignTransaction.name, Method.ETHSendTransaction.name, - Method.SolanaSignMessage.name, ) val solanaSupporedMethods = listOf( Method.SolanaSignMessage.name, Method.SolanaSignTransaction.name, ) +val bitcoinSupportedMethods = + listOf( + Method.BtcGetAccountAddresses.name, + Method.BtcSignMessage.name, + ) 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..12711b514a 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,6 +44,7 @@ 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 @@ -73,7 +74,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 +275,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 +290,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 +337,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 +362,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 +381,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 +437,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 +510,32 @@ 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 { + val chainIds = sessionProposal.getProposalChainIds() + val accounts = buildList { + if (chainIds.any { it.startsWith("eip155:") } && Web3Signer.evmAddress.isNotBlank()) { + add("EVM: ${Web3Signer.evmAddress}") + } + if (Chain.Solana.chainId in chainIds && Web3Signer.solanaAddress.isNotBlank()) { + add("${Chain.Solana.name}: ${Web3Signer.solanaAddress}") + } + if (Chain.Bitcoin.chainId in chainIds && Web3Signer.btcAddress.isNotBlank()) { + add("${Chain.Bitcoin.name}: ${Web3Signer.btcAddress}") + } + } + return accounts.joinToString("\n").ifBlank { Web3Signer.address } + } + 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..966707ca15 --- /dev/null +++ b/app/src/test/java/one/mixin/android/tip/wc/internal/WalletConnectNamespaceTest.kt @@ -0,0 +1,59 @@ +package one.mixin.android.tip.wc.internal + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WalletConnectNamespaceTest { + @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), namespaces.getValue("solana").chains) + assertEquals(listOf("${Chain.Solana.chainId}:$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)) + } +} From 296eaa3df6dc92bc8a282fa6b44e76702d76b10f Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:15:26 +0900 Subject: [PATCH 2/5] Disconnect WalletConnect sessions missing chain addresses --- .../mixin/android/tip/wc/WalletConnectV2.kt | 24 ++---- .../mixin/android/tip/wc/internal/Chain.kt | 22 +++++ .../wc/internal/WalletConnectNamespaceTest.kt | 86 +++++++++++++++++++ 3 files changed, 114 insertions(+), 18 deletions(-) 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 4e9a924b93..d71419c067 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 @@ -29,7 +29,7 @@ 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.accountForChainId +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 @@ -522,23 +522,11 @@ object WalletConnectV2 : WalletConnect() { return } sessions.forEach { session -> - val newNamespaces = session.namespaces.mapValues { (_, ns) -> - val chains = ns.chains - if (chains.isNullOrEmpty()) { - Timber.w("$TAG switchAccount: namespace has no chains, skipping update for it") - return@mapValues ns - } - val newAccounts = chains.mapNotNull { chainId -> - addresses.accountForChainId(chainId)?.let { address -> "$chainId:$address" } - } - if (newAccounts.isEmpty()) return@mapValues ns - - Wallet.Model.Namespace.Session( - chains = ns.chains, - accounts = newAccounts, - 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( 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 0bbc857ecb..a0d49832c2 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 @@ -84,6 +84,28 @@ internal fun WalletConnectAddresses.accountForChainId(chainId: String): String? 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 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 index 966707ca15..27d993047b 100644 --- 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 @@ -1,8 +1,10 @@ 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 { @@ -56,4 +58,88 @@ class WalletConnectNamespaceTest { assertEquals(addresses.solana, addresses.accountForChainId(Chain.Solana.chainId)) assertEquals(addresses.bitcoin, addresses.accountForChainId(Chain.Bitcoin.chainId)) } + + @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(Chain.Solana.chainId), + accounts = listOf("${Chain.Solana.chainId}:OldSolanaAddress"), + methods = solanaSupporedMethods, + 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("${Chain.Solana.chainId}:$solanaAddress"), updated?.getValue("solana")?.accounts) + assertEquals(listOf("${Chain.Bitcoin.chainId}:$bitcoinAddress"), updated?.getValue("bip122")?.accounts) + } } From 43284294113039c554acd5b16d84f2c791cf2683 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:05:52 +0900 Subject: [PATCH 3/5] Tighten WalletConnect multi-chain request handling --- .../mixin/android/tip/wc/WalletConnectV2.kt | 5 ++++ .../mixin/android/tip/wc/internal/Chain.kt | 25 +------------------ .../mixin/android/tip/wc/internal/Method.kt | 15 ++++++++++- .../wc/internal/WalletConnectAccountText.kt | 17 +++++++++++++ .../WalletConnectBottomSheetDialogFragment.kt | 23 ++++++++--------- .../wc/internal/WalletConnectNamespaceTest.kt | 22 +++++++++++++++- 6 files changed, 68 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/tip/wc/internal/WalletConnectAccountText.kt 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 d71419c067..fb2f34e0f6 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 @@ -34,6 +34,7 @@ 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 @@ -305,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 -> { 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 a0d49832c2..03da8f7931 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 @@ -153,29 +153,6 @@ fun getSupportedNamespaces(addresses: WalletConnectAddresses): Map { - return when { - chain == Chain.Solana -> { - getSolanaNamespaces(address) - } - - evmChainList.contains(chain) -> { - getEvmNamespaces(address) - } - - chain == Chain.Bitcoin -> { - getBitcoinNamespaces(address) - } - - else -> { - throw IllegalArgumentException("Not supported chain ${chain.name}") - } - } -} - private fun getEvmNamespaces(address: String): Map { val chainIds = evmChainList.map { chain -> chain.chainId } val accounts = evmChainList.map { chain -> "${chain.chainNamespace}:${chain.chainReference}:$address" } @@ -207,7 +184,7 @@ private fun getSolanaNamespaces(address: String): Map 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..42fca231b7 --- /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 (Chain.Solana.chainId in chainIds && 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/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/tip/wc/WalletConnectBottomSheetDialogFragment.kt index 12711b514a..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 @@ -49,8 +49,10 @@ 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 @@ -518,19 +520,14 @@ class WalletConnectBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } private fun proposalAccountText(sessionProposal: Wallet.Model.SessionProposal): String { - val chainIds = sessionProposal.getProposalChainIds() - val accounts = buildList { - if (chainIds.any { it.startsWith("eip155:") } && Web3Signer.evmAddress.isNotBlank()) { - add("EVM: ${Web3Signer.evmAddress}") - } - if (Chain.Solana.chainId in chainIds && Web3Signer.solanaAddress.isNotBlank()) { - add("${Chain.Solana.name}: ${Web3Signer.solanaAddress}") - } - if (Chain.Bitcoin.chainId in chainIds && Web3Signer.btcAddress.isNotBlank()) { - add("${Chain.Bitcoin.name}: ${Web3Signer.btcAddress}") - } - } - return accounts.joinToString("\n").ifBlank { Web3Signer.address } + return formatProposalAccountText( + sessionProposal.getProposalChainIds(), + WalletConnectAddresses( + evm = Web3Signer.evmAddress, + solana = Web3Signer.solanaAddress, + bitcoin = Web3Signer.btcAddress, + ), + ) } private fun isSignEvmTransaction() = signData != null && signData?.signMessage is WCEthereumTransaction 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 index 27d993047b..4a40ea5e90 100644 --- 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 @@ -59,6 +59,26 @@ class WalletConnectNamespaceTest { 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 sessionNamespaceUpdateReturnsNullWhenWalletNoLongerHasAConnectedChainAddress() { val namespaces = @@ -113,7 +133,7 @@ class WalletConnectNamespaceTest { Wallet.Model.Namespace.Session( chains = listOf(Chain.Solana.chainId), accounts = listOf("${Chain.Solana.chainId}:OldSolanaAddress"), - methods = solanaSupporedMethods, + methods = solanaSupportedMethods, events = emptyList(), ), "bip122" to From f6338ba132a58575b1306d5d66df5a43088de29d Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:40:32 +0900 Subject: [PATCH 4/5] Use standard Solana WalletConnect chain id --- .../main/java/one/mixin/android/tip/wc/internal/Chain.kt | 6 +++--- .../android/tip/wc/internal/WalletConnectNamespaceTest.kt | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) 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 03da8f7931..d7be30eb57 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 @@ -33,7 +33,7 @@ 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")) object Bitcoin : Chain(BITCOIN_CHAIN_ID, "bip122", "000000000019d6689c085ae165831e93", "000000000019d6689c085ae165831e93", "Bitcoin", "BTC", listOf("")) @@ -183,10 +183,10 @@ private fun getSolanaNamespaces(address: String): Map Date: Fri, 12 Jun 2026 12:51:00 +0900 Subject: [PATCH 5/5] Support legacy Solana WalletConnect chain id --- .../mixin/android/tip/wc/WalletConnectV2.kt | 4 +- .../mixin/android/tip/wc/internal/Chain.kt | 50 +++++++++---------- .../wc/internal/WalletConnectAccountText.kt | 2 +- .../wc/internal/WalletConnectNamespaceTest.kt | 37 ++++++++++++-- 4 files changed, 58 insertions(+), 35 deletions(-) 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 fb2f34e0f6..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 @@ -175,8 +175,8 @@ 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 { 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 d7be30eb57..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,7 +34,16 @@ 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", "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "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, "bip122", "000000000019d6689c085ae165831e93", "000000000019d6689c085ae165831e93", "Bitcoin", "BTC", listOf("")) @@ -42,11 +52,16 @@ sealed class Chain( 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) { @@ -107,36 +122,16 @@ internal fun buildUpdatedNamespaces( } 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 - Chain.Bitcoin.chainId -> Chain.Bitcoin - 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 - Chain.Bitcoin.chainId -> Chain.Bitcoin - else -> null + return supportChainList.firstOrNull { chain -> + chain.supportsWalletConnectChainId(chainId) } } @@ -180,13 +175,14 @@ private fun getBitcoinNamespaces(address: String): Map { + val chainIds = Chain.Solana.walletConnectChainIds return mapOf( "solana" to Wallet.Model.Namespace.Session( - chains = listOf(Chain.Solana.chainId), + chains = chainIds, methods = solanaSupportedMethods, events = listOf(""), - accounts = listOf("${Chain.Solana.chainId}:$address"), + accounts = chainIds.map { chainId -> "$chainId:$address" }, ), ) } 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 index 42fca231b7..385d5357ab 100644 --- 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 @@ -8,7 +8,7 @@ internal fun formatProposalAccountText( if (chainIds.any { it.startsWith("eip155:") } && addresses.evm.isNotBlank()) { add("EVM: ${addresses.evm}") } - if (Chain.Solana.chainId in chainIds && addresses.solana.isNotBlank()) { + 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()) { 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 index eebf53ba9f..908db4a9c5 100644 --- 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 @@ -8,12 +8,28 @@ 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" @@ -32,8 +48,8 @@ class WalletConnectNamespaceTest { 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), namespaces.getValue("solana").chains) - assertEquals(listOf("${Chain.Solana.chainId}:$solanaAddress"), namespaces.getValue("solana").accounts) + 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)) @@ -85,6 +101,17 @@ class WalletConnectNamespaceTest { 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 = @@ -137,8 +164,8 @@ class WalletConnectNamespaceTest { ), "solana" to Wallet.Model.Namespace.Session( - chains = listOf(Chain.Solana.chainId), - accounts = listOf("${Chain.Solana.chainId}:OldSolanaAddress"), + chains = listOf(legacySolanaChainId), + accounts = listOf("$legacySolanaChainId:OldSolanaAddress"), methods = solanaSupportedMethods, events = emptyList(), ), @@ -165,7 +192,7 @@ class WalletConnectNamespaceTest { listOf("${Chain.Ethereum.chainId}:$evmAddress", "${Chain.Base.chainId}:$evmAddress"), updated?.getValue("eip155")?.accounts, ) - assertEquals(listOf("${Chain.Solana.chainId}:$solanaAddress"), updated?.getValue("solana")?.accounts) + assertEquals(listOf("$legacySolanaChainId:$solanaAddress"), updated?.getValue("solana")?.accounts) assertEquals(listOf("${Chain.Bitcoin.chainId}:$bitcoinAddress"), updated?.getValue("bip122")?.accounts) } }