From cf1e79862f6606727f6ec548926bb921a6ef2f2b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 14 Aug 2026 14:29:31 +0800 Subject: [PATCH 1/5] feat(web3): add X Layer chain support Register X Layer (eip155:196) across chain IDs, WalletConnect, deposit URIs, and token metadata. --- .../main/java/one/mixin/android/Constants.kt | 7 ++- .../mixin/android/db/web3/vo/Web3TokenItem.kt | 31 +++++++---- .../android/extension/ContextExtension.kt | 3 +- .../java/one/mixin/android/pay/DepositUri.kt | 10 ++++ .../java/one/mixin/android/pay/Ethereum.kt | 1 + .../mixin/android/tip/wc/internal/Chain.kt | 7 ++- .../android/ui/wallet/DepositFragment.kt | 1 + .../components/ImportWalletDetailPage.kt | 3 +- .../one/mixin/android/util/ChainNetwork.kt | 4 ++ .../one/mixin/android/web3/Web3ChainId.kt | 4 +- .../one/mixin/android/web3/js/Web3Signer.kt | 36 +++++++------ .../android/XLayerChainClassificationTest.kt | 16 ++++++ .../android/compose/XLayerDepositUriTest.kt | 38 ++++++++++++++ .../android/db/web3/vo/XLayerTokenItemTest.kt | 52 +++++++++++++++++++ .../android/pay/XLayerEthereumUriTest.kt | 32 ++++++++++++ .../wc/internal/WalletConnectNamespaceTest.kt | 13 +++++ .../android/util/XLayerChainNetworkTest.kt | 13 +++++ .../mixin/android/web3/XLayerChainIdTest.kt | 16 ++++++ .../android/web3/js/Web3SignerNetworkTest.kt | 6 +++ 19 files changed, 260 insertions(+), 33 deletions(-) create mode 100644 app/src/test/java/one/mixin/android/XLayerChainClassificationTest.kt create mode 100644 app/src/test/java/one/mixin/android/compose/XLayerDepositUriTest.kt create mode 100644 app/src/test/java/one/mixin/android/db/web3/vo/XLayerTokenItemTest.kt create mode 100644 app/src/test/java/one/mixin/android/pay/XLayerEthereumUriTest.kt create mode 100644 app/src/test/java/one/mixin/android/util/XLayerChainNetworkTest.kt create mode 100644 app/src/test/java/one/mixin/android/web3/XLayerChainIdTest.kt diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index 9f49328bb1..4568af4d69 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -15,6 +15,7 @@ import one.mixin.android.Constants.ChainId.ETHEREUM_CHAIN_ID import one.mixin.android.Constants.ChainId.LIGHTNING_NETWORK_CHAIN_ID import one.mixin.android.Constants.ChainId.Optimism import one.mixin.android.Constants.ChainId.Polygon +import one.mixin.android.Constants.ChainId.XLayer import one.mixin.android.Constants.RouteConfig.ROUTE_BOT_USER_ID import one.mixin.android.net.CustomDns import one.mixin.android.net.SequentialDns @@ -270,8 +271,8 @@ object Constants { } - val Web3EvmChainIds = listOf(ETHEREUM_CHAIN_ID, Polygon, BinanceSmartChain, Base, Arbitrum, Optimism, Avalanche, ChainId.HyperEVM) - val Web3ChainIds = listOf(ETHEREUM_CHAIN_ID, Polygon, BinanceSmartChain, Base, Arbitrum, Optimism, Avalanche, ChainId.HyperEVM) + val Web3EvmChainIds = listOf(ETHEREUM_CHAIN_ID, Polygon, BinanceSmartChain, Base, Arbitrum, Optimism, Avalanche, ChainId.HyperEVM, XLayer) + val Web3ChainIds = listOf(ETHEREUM_CHAIN_ID, Polygon, BinanceSmartChain, Base, Arbitrum, Optimism, Avalanche, ChainId.HyperEVM, XLayer) val Web3UtxoChainIds = listOf(BITCOIN_CHAIN_ID, ChainId.PEARL_CHAIN_ID) object ChainId { @@ -303,6 +304,8 @@ object Constants { const val HyperEVM = "36d23d9e-bf4e-3ede-a12d-26f1f1f9fd2f" + const val XLayer = "37f5a4d1-905f-3b34-8291-c37438c7dcfc" + const val TON_CHAIN_ID = "ef660437-d915-4e27-ad3f-632bfb6ba0ee" } diff --git a/app/src/main/java/one/mixin/android/db/web3/vo/Web3TokenItem.kt b/app/src/main/java/one/mixin/android/db/web3/vo/Web3TokenItem.kt index f8b2619894..9e45eb2071 100644 --- a/app/src/main/java/one/mixin/android/db/web3/vo/Web3TokenItem.kt +++ b/app/src/main/java/one/mixin/android/db/web3/vo/Web3TokenItem.kt @@ -89,6 +89,7 @@ data class Web3TokenItem( chainId == Constants.ChainId.Optimism -> "Optimism" chainId == Constants.ChainId.Polygon -> "Polygon" chainId == Constants.ChainId.BinanceSmartChain -> "BNB Chain" + chainId == Constants.ChainId.XLayer -> "X Layer" chainId == Constants.ChainId.BITCOIN_CHAIN_ID -> "Bitcoin" chainId == Constants.ChainId.PEARL_CHAIN_ID -> "Pearl" chainId == Constants.ChainId.SOLANA_CHAIN_ID -> "Solana" @@ -197,6 +198,7 @@ fun Web3TokenItem.getChainFromName(): Chain { chainId == Constants.ChainId.BinanceSmartChain-> Chain.BinanceSmartChain chainId == Constants.ChainId.Avalanche -> Chain.Avalanche chainId == Constants.ChainId.HyperEVM -> Chain.HyperEVM + chainId == Constants.ChainId.XLayer -> Chain.XLayer chainId == Constants.ChainId.SOLANA_CHAIN_ID -> Chain.Solana chainId == Constants.ChainId.BITCOIN_CHAIN_ID -> Chain.Bitcoin chainId == Constants.ChainId.PEARL_CHAIN_ID -> Chain.Bitcoin @@ -223,6 +225,7 @@ fun Web3TokenItem.getChainSymbolFromName(): String { chainId == Constants.ChainId.HyperEVM -> "HYPE" chainId == Constants.ChainId.BinanceSmartChain -> "BNB" chainId == Constants.ChainId.Polygon -> "POL" + chainId == Constants.ChainId.XLayer -> "OKB" chainId == Constants.ChainId.BITCOIN_CHAIN_ID -> "BTC" chainId == Constants.ChainId.PEARL_CHAIN_ID -> "PRL" chainId == Constants.ChainId.SOLANA_CHAIN_ID -> "SOL" @@ -230,6 +233,23 @@ fun Web3TokenItem.getChainSymbolFromName(): String { } } +fun Web3TokenItem.isNativeEvmAsset(): Boolean = + when (chainId) { + Constants.ChainId.Polygon -> + assetKey == "0x0000000000000000000000000000000000000000" || + assetKey == "0x0000000000000000000000000000000000001010" + Constants.ChainId.ETHEREUM_CHAIN_ID, + Constants.ChainId.Base, + Constants.ChainId.Optimism, + Constants.ChainId.Arbitrum, + Constants.ChainId.Avalanche, + Constants.ChainId.HyperEVM, + Constants.ChainId.BinanceSmartChain, + Constants.ChainId.XLayer, + -> assetKey == "0x0000000000000000000000000000000000000000" + else -> false + } + suspend fun Web3TokenItem.buildTransaction( rpc: Rpc, @@ -315,17 +335,8 @@ suspend fun Web3TokenItem.buildTransaction( return JsSignMessage(0, JsSignMessage.TYPE_RAW_TRANSACTION, data = tx, solanaTxSource = SolanaTxSource.InnerTransfer) } else if (chainId in Constants.Web3EvmChainIds) { Web3Signer.useEvm() - // (chainId.equals("blast", true) && assetKey == "0x0000000000000000000000000000000000000000") || val transaction = - if ((chainId == Constants.ChainId.Base && assetKey == "0x0000000000000000000000000000000000000000") || - (chainId == Constants.ChainId.ETHEREUM_CHAIN_ID && assetKey == "0x0000000000000000000000000000000000000000") || - (chainId == Constants.ChainId.Polygon && (assetKey == "0x0000000000000000000000000000000000000000" || assetKey == "0x0000000000000000000000000000000000001010")) || - (chainId == Constants.ChainId.BinanceSmartChain && assetKey == "0x0000000000000000000000000000000000000000") || - (chainId == Constants.ChainId.Optimism && assetKey == "0x0000000000000000000000000000000000000000") || - (chainId == Constants.ChainId.Arbitrum && assetKey == "0x0000000000000000000000000000000000000000") || - (chainId == Constants.ChainId.Avalanche && assetKey == "0x0000000000000000000000000000000000000000") || - (chainId == Constants.ChainId.HyperEVM && assetKey == "0x0000000000000000000000000000000000000000") - ) { + if (isNativeEvmAsset()) { val value = Numeric.toHexStringWithPrefix(Convert.toWei(v, Convert.Unit.ETHER).toBigInteger()) WCEthereumTransaction(fromAddress, toAddress, null, null, null, null, null, null, value, null) } else { diff --git a/app/src/main/java/one/mixin/android/extension/ContextExtension.kt b/app/src/main/java/one/mixin/android/extension/ContextExtension.kt index 04259917be..7d2c139470 100644 --- a/app/src/main/java/one/mixin/android/extension/ContextExtension.kt +++ b/app/src/main/java/one/mixin/android/extension/ContextExtension.kt @@ -1155,7 +1155,8 @@ fun Fragment.getTipsByAsset(asset: TokenItem) = Constants.ChainId.Polygon, Constants.ChainId.BitShares, Constants.ChainId.Avalanche, - Constants.ChainId.HyperEVM + Constants.ChainId.HyperEVM, + Constants.ChainId.XLayer, -> getString(R.string.deposit_tip_chain, asset.symbol, asset.chainName ?: getChainName(asset.chainId, asset.chainName, asset.assetKey ?: "")) else -> getString(R.string.deposit_tip_common, asset.symbol) } diff --git a/app/src/main/java/one/mixin/android/pay/DepositUri.kt b/app/src/main/java/one/mixin/android/pay/DepositUri.kt index 06a47c6637..b8bfb9b89e 100644 --- a/app/src/main/java/one/mixin/android/pay/DepositUri.kt +++ b/app/src/main/java/one/mixin/android/pay/DepositUri.kt @@ -104,6 +104,16 @@ internal fun generateDepositUri( } } + ChainId.XLayer -> { + if (assetId == ChainId.XLayer) { + val weiAmount = nativeEvmWeiAmount(cleanAmount) + "ethereum:$address@196?value=$weiAmount" + } else { + val uint256Amount = erc20Amount(cleanAmount, precision) + "ethereum:${assetKey}@196/transfer?address=$address&amount=$cleanAmount&uint256=$uint256Amount" + } + } + ChainId.Litecoin -> { "litecoin:$address?amount=$cleanAmount" } diff --git a/app/src/main/java/one/mixin/android/pay/Ethereum.kt b/app/src/main/java/one/mixin/android/pay/Ethereum.kt index 19bde8e188..af2c564b43 100644 --- a/app/src/main/java/one/mixin/android/pay/Ethereum.kt +++ b/app/src/main/java/one/mixin/android/pay/Ethereum.kt @@ -144,6 +144,7 @@ private val ethereumChainIdMap by lazy { 10 to Constants.ChainId.Optimism, 56 to Constants.ChainId.BinanceSmartChain, 137 to Constants.ChainId.Polygon, + 196 to Constants.ChainId.XLayer, 8453 to Constants.ChainId.Base, 42161 to Constants.ChainId.Arbitrum, 999 to Constants.ChainId.HyperEVM, 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 54d2e5ed5c..7a6048e618 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 @@ -34,6 +34,8 @@ sealed class Chain( object HyperEVM : Chain(Constants.ChainId.HyperEVM, "eip155", "999", "0x3e7", "HyperEVM", "HYPE", listOf("https://rpc.hyperliquid.xyz/evm")) + object XLayer : Chain(Constants.ChainId.XLayer, "eip155", "196", "0xc4", "X Layer", "OKB", listOf("https://rpc.xlayer.tech")) + object Solana : Chain( SOLANA_CHAIN_ID, "solana", @@ -73,13 +75,14 @@ sealed class Chain( Base -> Constants.ChainId.Base Avalanche -> Constants.ChainId.Avalanche HyperEVM -> Constants.ChainId.HyperEVM + XLayer -> Constants.ChainId.XLayer Solana -> Constants.ChainId.Solana Bitcoin -> BITCOIN_CHAIN_ID } } // Chain.Blast -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) +internal val supportChainList = listOf(Chain.Solana, Chain.Bitcoin, Chain.Ethereum, Chain.Base, Chain.BinanceSmartChain, Chain.Polygon, Chain.Optimism, Chain.Arbitrum, Chain.Avalanche, Chain.HyperEVM, Chain.XLayer) +internal val evmChainList = listOf(Chain.Ethereum, Chain.Base, Chain.BinanceSmartChain, Chain.Polygon, Chain.Optimism, Chain.Arbitrum, Chain.Avalanche, Chain.HyperEVM, Chain.XLayer) data class WalletConnectAddresses( val evm: String, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/DepositFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/DepositFragment.kt index 2a4f68e5df..bf2a7ac057 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/DepositFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/DepositFragment.kt @@ -348,6 +348,7 @@ class DepositFragment : BaseFragment() { Constants.ChainId.LIGHTNING_NETWORK_CHAIN_ID, Constants.ChainId.Avalanche, Constants.ChainId.HyperEVM, + Constants.ChainId.XLayer, Constants.ChainId.TON_CHAIN_ID -> true else -> false diff --git a/app/src/main/java/one/mixin/android/ui/wallet/components/ImportWalletDetailPage.kt b/app/src/main/java/one/mixin/android/ui/wallet/components/ImportWalletDetailPage.kt index 1a48ece9db..9e3c1f3447 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/components/ImportWalletDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/components/ImportWalletDetailPage.kt @@ -101,6 +101,7 @@ fun ImportWalletDetailPage( "Optimism" to Constants.ChainId.Optimism, "Avalanche" to Constants.ChainId.Avalanche, "HyperEVM" to Constants.ChainId.HyperEVM, + "X Layer" to Constants.ChainId.XLayer, "Solana" to Constants.ChainId.SOLANA_CHAIN_ID ) var expanded by remember { mutableStateOf(false) } @@ -126,7 +127,7 @@ fun ImportWalletDetailPage( val isEvmNetwork = when (selectedNetworkName) { - "Ethereum", "Base", "BSC", "Polygon", "Arbitrum", "Optimism", "Avalanche", "HyperEVM" -> true + "Ethereum", "Base", "BSC", "Polygon", "Arbitrum", "Optimism", "Avalanche", "HyperEVM", "X Layer" -> true else -> false } val isBitcoin: Boolean = selectedNetworkName == "Bitcoin" diff --git a/app/src/main/java/one/mixin/android/util/ChainNetwork.kt b/app/src/main/java/one/mixin/android/util/ChainNetwork.kt index 6a981e650f..448af4b977 100644 --- a/app/src/main/java/one/mixin/android/util/ChainNetwork.kt +++ b/app/src/main/java/one/mixin/android/util/ChainNetwork.kt @@ -17,6 +17,7 @@ private val chainNetworks by lazy { Constants.ChainId.Optimism to "Optimism", Constants.ChainId.Arbitrum to "Arbitrum", Constants.ChainId.HyperEVM to "HyperEVM", + Constants.ChainId.XLayer to "X Layer", ) } @@ -42,6 +43,8 @@ fun getChainNetwork( return "Arbitrum" } else if (chainId == Constants.ChainId.Optimism) { return "Optimism" + } else if (chainId == Constants.ChainId.XLayer) { + return "X Layer" } if (assetId == chainId && !bepChains.contains(chainId) && assetId != Constants.ChainId.LIGHTNING_NETWORK_CHAIN_ID) return null @@ -64,6 +67,7 @@ private val chainNames by lazy { Constants.ChainId.BitShares to "BitShares", Constants.ChainId.BinanceSmartChain to "BNB Smart Chain (BEP-20)", Constants.ChainId.LIGHTNING_NETWORK_CHAIN_ID to "Lightning", + Constants.ChainId.XLayer to "X Layer", ) } diff --git a/app/src/main/java/one/mixin/android/web3/Web3ChainId.kt b/app/src/main/java/one/mixin/android/web3/Web3ChainId.kt index 8659e8f9a2..f762c2e9bd 100644 --- a/app/src/main/java/one/mixin/android/web3/Web3ChainId.kt +++ b/app/src/main/java/one/mixin/android/web3/Web3ChainId.kt @@ -14,13 +14,14 @@ object Web3ChainId { const val OptimismChainId = 10 const val BscChainId = 56 const val PolygonChainId = 137 + const val XLayerChainId = 196 const val BaseChainId = 8453 const val ArbitrumChainId = 42161 const val AvalancheChainId = 43114 const val BlastChainId = 81457 const val HyperEvmChainId = 999 - val eip155ChainIds = listOf(EthChainId, OptimismChainId, BscChainId, PolygonChainId, BaseChainId, ArbitrumChainId, AvalancheChainId, BlastChainId, HyperEvmChainId) + val eip155ChainIds = listOf(EthChainId, OptimismChainId, BscChainId, PolygonChainId, XLayerChainId, BaseChainId, ArbitrumChainId, AvalancheChainId, BlastChainId, HyperEvmChainId) fun getChainType(id: Int): ChainType = when { @@ -35,6 +36,7 @@ object Web3ChainId { OptimismChainId -> Chain.Optimism BscChainId -> Chain.BinanceSmartChain PolygonChainId -> Chain.Polygon + XLayerChainId -> Chain.XLayer BaseChainId -> Chain.Base ArbitrumChainId -> Chain.Arbitrum AvalancheChainId -> Chain.Avalanche 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 50cf78c792..1cb569eb82 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 @@ -57,6 +57,20 @@ import java.math.BigInteger import java.nio.ByteBuffer import org.sol4k.Constants as ConstantsSolana +internal fun findChainByHexReference(hex: String?): Chain? = + listOf( + Chain.Ethereum, + Chain.Base, + Chain.Arbitrum, + Chain.Optimism, + Chain.Avalanche, + Chain.Polygon, + Chain.BinanceSmartChain, + Chain.HyperEVM, + Chain.XLayer, + Chain.Solana, + ).firstOrNull { it.hexReference == hex } + object Web3Signer { sealed class JsSignerNetwork(val name: String) { data object Ethereum : JsSignerNetwork("ethereum") @@ -127,7 +141,7 @@ object Web3Signer { currentWalletCategory = sp.getString(Keys.CURRENT_WALLET_CATEGORY, WalletCategory.CLASSIC.value) ?: WalletCategory.CLASSIC.value classicWalletId = sp.getString(Keys.CLASSIC_WALLET_ID, "") ?: "" - currentChain = findChainByHex(sp.getString(Keys.CURRENT_CHAIN, Chain.Ethereum.hexReference)) + currentChain = findChainByHexReference(sp.getString(Keys.CURRENT_CHAIN, Chain.Ethereum.hexReference)) ?: Chain.Ethereum currentNetwork = if (currentChain == Chain.Solana) JsSignerNetwork.Solana.name else JsSignerNetwork.Ethereum.name } @@ -145,21 +159,6 @@ object Web3Signer { sp.putString(Keys.CURRENT_CHAIN, currentChain.hexReference) } - private fun findChainByHex(hex: String?): Chain? { - return when (hex) { - Chain.Ethereum.hexReference -> Chain.Ethereum - Chain.Base.hexReference -> Chain.Base - Chain.Arbitrum.hexReference -> Chain.Arbitrum - Chain.Optimism.hexReference -> Chain.Optimism - Chain.Avalanche.hexReference -> Chain.Avalanche - Chain.Polygon.hexReference -> Chain.Polygon - Chain.BinanceSmartChain.hexReference -> Chain.BinanceSmartChain - Chain.HyperEVM.hexReference -> Chain.HyperEVM - Chain.Solana.hexReference -> Chain.Solana - else -> null - } - } - fun updateAddress( network: String, address: String, @@ -312,6 +311,11 @@ object Web3Signer { persist() Result.success(Chain.HyperEVM.name) } + Chain.XLayer.hexReference -> { + currentChain = Chain.XLayer + persist() + Result.success(Chain.XLayer.name) + } Chain.Solana.hexReference -> { currentChain = Chain.Solana currentNetwork = JsSignerNetwork.Solana.name diff --git a/app/src/test/java/one/mixin/android/XLayerChainClassificationTest.kt b/app/src/test/java/one/mixin/android/XLayerChainClassificationTest.kt new file mode 100644 index 0000000000..c4c2bc6119 --- /dev/null +++ b/app/src/test/java/one/mixin/android/XLayerChainClassificationTest.kt @@ -0,0 +1,16 @@ +package one.mixin.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class XLayerChainClassificationTest { + @Test + fun xLayerUsesExpectedMixinChainIdAndEvmClassification() { + assertEquals("37f5a4d1-905f-3b34-8291-c37438c7dcfc", Constants.ChainId.XLayer) + assertTrue(Constants.ChainId.XLayer in Constants.Web3EvmChainIds) + assertTrue(Constants.ChainId.XLayer in Constants.Web3ChainIds) + assertFalse(Constants.ChainId.XLayer in Constants.Web3UtxoChainIds) + } +} diff --git a/app/src/test/java/one/mixin/android/compose/XLayerDepositUriTest.kt b/app/src/test/java/one/mixin/android/compose/XLayerDepositUriTest.kt new file mode 100644 index 0000000000..2ad3817621 --- /dev/null +++ b/app/src/test/java/one/mixin/android/compose/XLayerDepositUriTest.kt @@ -0,0 +1,38 @@ +package one.mixin.android.compose + +import one.mixin.android.Constants +import one.mixin.android.pay.generateDepositUri +import org.junit.Assert.assertEquals +import org.junit.Test + +class XLayerDepositUriTest { + @Test + fun nativeOkbDepositUriUsesXLayerChainId() { + assertEquals( + "ethereum:0x1111111111111111111111111111111111111111@196?value=1500000000000000000", + generateDepositUri( + assetId = Constants.ChainId.XLayer, + chainId = Constants.ChainId.XLayer, + assetKey = "0x0000000000000000000000000000000000000000", + address = "0x1111111111111111111111111111111111111111", + amount = "1.5", + precision = 18, + ), + ) + } + + @Test + fun xLayerErc20DepositUriUsesTransferCall() { + assertEquals( + "ethereum:0x2222222222222222222222222222222222222222@196/transfer?address=0x1111111111111111111111111111111111111111&amount=1.5&uint256=1500000", + generateDepositUri( + assetId = "token", + chainId = Constants.ChainId.XLayer, + assetKey = "0x2222222222222222222222222222222222222222", + address = "0x1111111111111111111111111111111111111111", + amount = "1.5", + precision = 6, + ), + ) + } +} diff --git a/app/src/test/java/one/mixin/android/db/web3/vo/XLayerTokenItemTest.kt b/app/src/test/java/one/mixin/android/db/web3/vo/XLayerTokenItemTest.kt new file mode 100644 index 0000000000..35baf483f1 --- /dev/null +++ b/app/src/test/java/one/mixin/android/db/web3/vo/XLayerTokenItemTest.kt @@ -0,0 +1,52 @@ +package one.mixin.android.db.web3.vo + +import one.mixin.android.Constants +import one.mixin.android.crypto.CryptoWalletHelper +import one.mixin.android.tip.wc.internal.Chain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class XLayerTokenItemTest { + @Test + fun xLayerTokenUsesExpectedDisplayAndNativeAssetMetadata() { + val nativeToken = token("0x0000000000000000000000000000000000000000") + val erc20Token = token("0x1111111111111111111111111111111111111111") + + assertEquals("X Layer", nativeToken.getChainDisplayName()) + assertEquals("OKB", nativeToken.getChainSymbolFromName()) + assertEquals(Chain.XLayer, nativeToken.getChainFromName()) + assertTrue(nativeToken.isNativeEvmAsset()) + assertFalse(erc20Token.isNativeEvmAsset()) + } + + @Test + fun xLayerReusesEthereumAddressDerivation() { + val privateKey = "0000000000000000000000000000000000000000000000000000000000000001" + + assertEquals( + CryptoWalletHelper.privateKeyToAddress(privateKey, Constants.ChainId.ETHEREUM_CHAIN_ID), + CryptoWalletHelper.privateKeyToAddress(privateKey, Constants.ChainId.XLayer), + ) + } + + private fun token(assetKey: String) = Web3TokenItem( + walletId = "wallet", + assetId = Constants.ChainId.XLayer, + chainId = Constants.ChainId.XLayer, + name = "OKB", + assetKey = assetKey, + symbol = "OKB", + iconUrl = "", + precision = 18, + balance = "0", + priceUsd = "0", + changeUsd = "0", + chainIcon = null, + chainName = null, + chainSymbol = null, + hidden = false, + level = 1, + ) +} diff --git a/app/src/test/java/one/mixin/android/pay/XLayerEthereumUriTest.kt b/app/src/test/java/one/mixin/android/pay/XLayerEthereumUriTest.kt new file mode 100644 index 0000000000..6e89cb2ba7 --- /dev/null +++ b/app/src/test/java/one/mixin/android/pay/XLayerEthereumUriTest.kt @@ -0,0 +1,32 @@ +package one.mixin.android.pay + +import kotlinx.coroutines.runBlocking +import one.mixin.android.Constants +import one.mixin.android.api.response.AddressResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class XLayerEthereumUriTest { + @Test + fun erc681ChainIdResolvesToXLayer() = runBlocking { + val destination = "0x1111111111111111111111111111111111111111" + var validatedChainId: String? = null + + val transfer = parseEthereum( + url = "ethereum:$destination@196", + validateAddress = { assetId, chainId, address -> + validatedChainId = chainId + AddressResponse(address, assetId = assetId) + }, + getFee = { _, _ -> emptyList() }, + findAssetIdByAssetKey = { null }, + getAssetPrecisionById = { null }, + balanceCheck = { _, _, _, _ -> }, + ) + + assertNotNull(transfer) + assertEquals(Constants.ChainId.XLayer, validatedChainId) + assertEquals(Constants.ChainId.XLayer, transfer?.assetId) + } +} 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 915669c968..5ec614c7b5 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 @@ -10,6 +10,19 @@ import kotlin.test.assertTrue class WalletConnectNamespaceTest { private val legacySolanaChainId = "solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ" + @Test + fun xLayerUsesExpectedWalletConnectMetadata() { + assertEquals("37f5a4d1-905f-3b34-8291-c37438c7dcfc", Chain.XLayer.assetId) + assertEquals("eip155:196", Chain.XLayer.chainId) + assertEquals("0xc4", Chain.XLayer.hexReference) + assertEquals("X Layer", Chain.XLayer.name) + assertEquals("OKB", Chain.XLayer.symbol) + assertEquals(Chain.XLayer.assetId, Chain.XLayer.getWeb3ChainId()) + assertEquals(Chain.XLayer, getChainByChainId("eip155:196")) + assertEquals(Chain.XLayer, "196".getChain()) + assertTrue(Chain.XLayer in evmChainList) + } + @Test fun solanaChainIdUsesMainnetCaip2Reference() { assertEquals("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", Chain.Solana.chainId) diff --git a/app/src/test/java/one/mixin/android/util/XLayerChainNetworkTest.kt b/app/src/test/java/one/mixin/android/util/XLayerChainNetworkTest.kt new file mode 100644 index 0000000000..c758fb5129 --- /dev/null +++ b/app/src/test/java/one/mixin/android/util/XLayerChainNetworkTest.kt @@ -0,0 +1,13 @@ +package one.mixin.android.util + +import one.mixin.android.Constants +import org.junit.Assert.assertEquals +import org.junit.Test + +class XLayerChainNetworkTest { + @Test + fun xLayerFallbacksUseDisplayName() { + assertEquals("X Layer", getChainNetwork("token", Constants.ChainId.XLayer, "asset-key")) + assertEquals("X Layer", getChainName(Constants.ChainId.XLayer, null, "asset-key")) + } +} diff --git a/app/src/test/java/one/mixin/android/web3/XLayerChainIdTest.kt b/app/src/test/java/one/mixin/android/web3/XLayerChainIdTest.kt new file mode 100644 index 0000000000..fa0c4b5f9b --- /dev/null +++ b/app/src/test/java/one/mixin/android/web3/XLayerChainIdTest.kt @@ -0,0 +1,16 @@ +package one.mixin.android.web3 + +import one.mixin.android.tip.wc.internal.Chain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class XLayerChainIdTest { + @Test + fun numericChainIdResolvesToXLayerEvm() { + assertEquals(196, Web3ChainId.XLayerChainId) + assertTrue(Web3ChainId.XLayerChainId in Web3ChainId.eip155ChainIds) + assertEquals(ChainType.ethereum, Web3ChainId.getChainType(Web3ChainId.XLayerChainId)) + assertEquals(Chain.XLayer, Web3ChainId.getChain(Web3ChainId.XLayerChainId)) + } +} diff --git a/app/src/test/java/one/mixin/android/web3/js/Web3SignerNetworkTest.kt b/app/src/test/java/one/mixin/android/web3/js/Web3SignerNetworkTest.kt index ff6cd1708b..e035d77e33 100644 --- a/app/src/test/java/one/mixin/android/web3/js/Web3SignerNetworkTest.kt +++ b/app/src/test/java/one/mixin/android/web3/js/Web3SignerNetworkTest.kt @@ -1,5 +1,6 @@ package one.mixin.android.web3.js +import one.mixin.android.tip.wc.internal.Chain import org.junit.Assert.assertEquals import org.junit.Test @@ -8,4 +9,9 @@ class Web3SignerNetworkTest { fun pearlUsesStableNetworkName() { assertEquals("pearl", Web3Signer.JsSignerNetwork.Pearl.name) } + + @Test + fun xLayerHexReferenceResolvesForPersistedChainState() { + assertEquals(Chain.XLayer, findChainByHexReference("0xc4")) + } } From f7cd1662ab99406e456889da5dafe41a9a7557e8 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 21 Aug 2026 14:10:18 +0800 Subject: [PATCH 2/5] fix(web3): use chain fee for zero sponsor fee --- .../android/db/web3/vo/Web3TransactionItem.kt | 9 ++++++--- .../mixin/android/ui/wallet/Web3PendingFeeTest.kt | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/web3/vo/Web3TransactionItem.kt b/app/src/main/java/one/mixin/android/db/web3/vo/Web3TransactionItem.kt index 9409cc94ab..0b91ff10d0 100644 --- a/app/src/main/java/one/mixin/android/db/web3/vo/Web3TransactionItem.kt +++ b/app/src/main/java/one/mixin/android/db/web3/vo/Web3TransactionItem.kt @@ -107,11 +107,14 @@ data class Web3TransactionItem( fun isNotVerified() = level < Constants.AssetLevel.VERIFIED - fun displayFeeAmount(): String = sponsorFeeAmount?.ifBlank { fee } ?: fee + fun displayFeeAmount(): String = if (hasSponsorFee()) sponsorFeeAmount.orEmpty() else fee - fun displayFeeSymbol(): String? = sponsorFeeAssetSymbol ?: chainSymbol + fun displayFeeSymbol(): String? = if (hasSponsorFee()) sponsorFeeAssetSymbol ?: chainSymbol else chainSymbol - fun hasSponsorFee(): Boolean = !sponsorFeeAmount.isNullOrBlank() + fun hasSponsorFee(): Boolean { + val amount = sponsorFeeAmount?.takeIf { it.isNotBlank() } ?: return false + return amount.toBigDecimalOrNull()?.signum()?.let { it != 0 } ?: true + } fun getMainAmount(): String { return when (transactionType) { diff --git a/app/src/test/java/one/mixin/android/ui/wallet/Web3PendingFeeTest.kt b/app/src/test/java/one/mixin/android/ui/wallet/Web3PendingFeeTest.kt index 1c3bedd9ce..a9dade1a26 100644 --- a/app/src/test/java/one/mixin/android/ui/wallet/Web3PendingFeeTest.kt +++ b/app/src/test/java/one/mixin/android/ui/wallet/Web3PendingFeeTest.kt @@ -2,6 +2,7 @@ package one.mixin.android.ui.wallet import one.mixin.android.db.web3.vo.Web3TransactionItem import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import org.junit.Test @@ -40,6 +41,20 @@ class Web3PendingFeeTest { assertEquals("ETH", transaction.displayFeeSymbol()) } + @Test + fun chainFeeIsUsedWhenSponsorFeeIsZero() { + val transaction = transactionItem( + fee = "0.0001", + sponsorFeeAmount = "0", + chainSymbol = "ETH", + sponsorFeeAssetSymbol = "USDT", + ) + + assertEquals("0.0001", transaction.displayFeeAmount()) + assertEquals("ETH", transaction.displayFeeSymbol()) + assertFalse(transaction.hasSponsorFee()) + } + private fun transactionItem( fee: String, sponsorFeeAmount: String? = null, From 4eba7454e32a78aba44208122e7b5569e51c1a15 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 24 Aug 2026 13:46:30 +0800 Subject: [PATCH 3/5] fix(web3): sync refreshed transaction colors --- .../details/Web3TransactionDetailState.kt | 28 +++ .../web3/details/Web3TransactionFragment.kt | 160 ++++++++++-------- .../details/Web3TransactionDetailStateTest.kt | 35 ++++ 3 files changed, 150 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/web3/details/Web3TransactionDetailState.kt create mode 100644 app/src/test/java/one/mixin/android/web3/details/Web3TransactionDetailStateTest.kt diff --git a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionDetailState.kt b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionDetailState.kt new file mode 100644 index 0000000000..2e902b8f50 --- /dev/null +++ b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionDetailState.kt @@ -0,0 +1,28 @@ +package one.mixin.android.web3.details + +import one.mixin.android.db.web3.vo.TransactionStatus +import one.mixin.android.db.web3.vo.TransactionType + +internal data class Web3TransactionDetailState( + val status: String, + val transactionType: String, +) { + val amountTone: Web3TransactionAmountTone + get() = when { + status == TransactionStatus.PENDING.value || + status == TransactionStatus.NOT_FOUND.value || + status == TransactionStatus.FAILED.value -> Web3TransactionAmountTone.ASSIST + transactionType == TransactionType.TRANSFER_OUT.value -> Web3TransactionAmountTone.OUTGOING + transactionType == TransactionType.TRANSFER_IN.value -> Web3TransactionAmountTone.INCOMING + else -> Web3TransactionAmountTone.PRIMARY + } + + fun withStatus(status: String): Web3TransactionDetailState = copy(status = status) +} + +internal enum class Web3TransactionAmountTone { + ASSIST, + OUTGOING, + INCOMING, + PRIMARY, +} diff --git a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt index eb32549508..35a90cf26a 100644 --- a/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/details/Web3TransactionFragment.kt @@ -9,6 +9,7 @@ import android.util.TypedValue import android.view.View import android.view.ViewGroup.MarginLayoutParams import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.runtime.mutableStateOf import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -111,6 +112,15 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) ) } + private val transactionDetailState by lazy { + mutableStateOf( + Web3TransactionDetailState( + status = transaction.status, + transactionType = transaction.transactionType, + ) + ) + } + private val chain by lazy { requireNotNull(requireArguments().getString(ARGS_CHAIN)) } @@ -128,8 +138,65 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) } } - private fun shouldShowValueDetails(): Boolean { - if (transaction.status != TransactionStatus.SUCCESS.value) return false + private fun bindMainValue(state: Web3TransactionDetailState) { + val amountColor = when (state.amountTone) { + Web3TransactionAmountTone.ASSIST -> requireContext().colorFromAttribute(R.attr.text_assist) + Web3TransactionAmountTone.OUTGOING -> requireContext().getColor(R.color.wallet_pink) + Web3TransactionAmountTone.INCOMING -> requireContext().getColor(R.color.wallet_green) + Web3TransactionAmountTone.PRIMARY -> requireContext().colorFromAttribute(R.attr.text_primary) + } + val symbolColor = requireContext().colorFromAttribute(R.attr.text_primary) + val mainAmount = transaction.getFormattedAmount() + + binding.valueTv.text = when (transaction.transactionType) { + TransactionType.SWAP.value -> { + binding.valueTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + binding.valueTv.setTypeface(binding.valueTv.typeface, Typeface.BOLD) + binding.valueTv.setTextColor(requireContext().colorFromAttribute(R.attr.text_primary)) + getString(R.string.Swap) + } + TransactionType.UNKNOWN.value -> { + binding.valueTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + binding.valueTv.setTypeface(binding.valueTv.typeface, Typeface.BOLD) + binding.valueTv.setTextColor(requireContext().colorFromAttribute(R.attr.text_primary)) + getString(R.string.Unknown) + } + TransactionType.APPROVAL.value -> { + binding.valueTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + binding.valueTv.setTypeface(binding.valueTv.typeface, Typeface.BOLD) + binding.valueTv.setTextColor(requireContext().colorFromAttribute(R.attr.text_primary)) + getString(R.string.Approval) + } + else -> { + if ((transaction.transactionType == TransactionType.TRANSFER_OUT.value && transaction.senders.size > 1) || (transaction.transactionType == TransactionType.TRANSFER_IN.value && transaction.receivers.size > 1)) { + binding.valueTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) + binding.valueTv.setTypeface(binding.valueTv.typeface, Typeface.BOLD) + binding.valueTv.setTextColor(requireContext().colorFromAttribute(R.attr.text_primary)) + if (transaction.transactionType == TransactionType.TRANSFER_OUT.value) { + getString(R.string.Send) + } else { + getString(R.string.Deposit) + } + } else { + buildAmountSymbol( + requireContext(), + formatAmountWithSign(mainAmount, transaction.transactionType == TransactionType.TRANSFER_IN.value), + when (transaction.transactionType) { + TransactionType.TRANSFER_OUT.value -> transaction.sendAssetSymbol ?: "" + TransactionType.APPROVAL.value -> transaction.sendAssetSymbol ?: "" + TransactionType.TRANSFER_IN.value -> transaction.receiveAssetSymbol ?: "" + else -> "" + }, + amountColor, + symbolColor, + ) + } + } + } + } + + private fun shouldShowValueDetails(status: String): Boolean { + if (status != TransactionStatus.SUCCESS.value) return false if (transaction.transactionType != TransactionType.TRANSFER_IN.value && transaction.transactionType != TransactionType.TRANSFER_OUT.value) { return false @@ -332,6 +399,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) ) } binding.root.isClickable = true + val currentStatus = transactionDetailState.value.status binding.apply { if (wallet.category == WalletCategory.CLASSIC.value || wallet.category == WalletCategory.IMPORTED_PRIVATE_KEY.value || @@ -344,66 +412,9 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) titleView.setWalletNameSubTitleStyle() spamLl.isVisible = transaction.isNotVerified() transactionHashTv.text = transaction.transactionHash - val amountColor = if (transaction.status == TransactionStatus.PENDING.value || transaction.status == TransactionStatus.NOT_FOUND.value || transaction.status == TransactionStatus.FAILED.value) { - requireContext().colorFromAttribute(R.attr.text_assist) - } else if (transaction.transactionType == TransactionType.TRANSFER_OUT.value) { - requireContext().getColor(R.color.wallet_pink) - } else if (transaction.transactionType == TransactionType.TRANSFER_IN.value) { - requireContext().getColor(R.color.wallet_green) - } else { - requireContext().colorFromAttribute(R.attr.text_primary) - } - - val symbolColor = requireContext().colorFromAttribute(R.attr.text_primary) - - val mainAmount = transaction.getFormattedAmount() - - valueTv.text = when (transaction.transactionType) { - TransactionType.SWAP.value -> { - valueTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) - valueTv.setTypeface(valueTv.typeface, Typeface.BOLD) - valueTv.setTextColor(requireContext().colorFromAttribute(R.attr.text_primary)) - getString(R.string.Swap) - } - TransactionType.UNKNOWN.value -> { - valueTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) - valueTv.setTypeface(valueTv.typeface, Typeface.BOLD) - valueTv.setTextColor(requireContext().colorFromAttribute(R.attr.text_primary)) - getString(R.string.Unknown) - } - TransactionType.APPROVAL.value -> { - valueTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) - valueTv.setTypeface(valueTv.typeface, Typeface.BOLD) - valueTv.setTextColor(requireContext().colorFromAttribute(R.attr.text_primary)) - getString(R.string.Approval) - } - else -> { - if ((transaction.transactionType == TransactionType.TRANSFER_OUT.value && transaction.senders.size > 1) || (transaction.transactionType == TransactionType.TRANSFER_IN.value && transaction.receivers.size > 1)) { - valueTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) - valueTv.setTypeface(valueTv.typeface, Typeface.BOLD) - valueTv.setTextColor(requireContext().colorFromAttribute(R.attr.text_primary)) - if (transaction.transactionType == TransactionType.TRANSFER_OUT.value) { - getString(R.string.Send) - } else { - getString(R.string.Deposit) - } - } else { - buildAmountSymbol( - requireContext(), - formatAmountWithSign(mainAmount, transaction.transactionType == TransactionType.TRANSFER_IN.value), - when (transaction.transactionType) { - TransactionType.TRANSFER_OUT.value -> transaction.sendAssetSymbol ?: "" - TransactionType.APPROVAL.value -> transaction.sendAssetSymbol ?: "" - TransactionType.TRANSFER_IN.value -> transaction.receiveAssetSymbol ?: "" - else -> "" - }, - amountColor, symbolColor - ) - } - } - } + bindMainValue(transactionDetailState.value) - when (transaction.status) { + when (currentStatus) { TransactionStatus.SUCCESS.value -> { status.text = getString(R.string.Completed) status.setTextColor(requireContext().getColor(R.color.wallet_green)) @@ -429,7 +440,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) } else -> { - status.text = transaction.status + status.text = currentStatus status.setTextColor(requireContext().colorFromAttribute(R.attr.text_assist)) status.setBackgroundResource(R.drawable.bg_status_default) } @@ -439,11 +450,11 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) val toAddress = transaction.getToAddress() when { - transaction.status == TransactionStatus.NOT_FOUND.value -> { + currentStatus == TransactionStatus.NOT_FOUND.value -> { fromLl.isVisible = false toLl.isVisible = false } - transaction.status == TransactionStatus.FAILED.value-> { + currentStatus == TransactionStatus.FAILED.value -> { valueTv.isVisible = false fromLl.isVisible = false toLl.isVisible = false @@ -474,7 +485,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) } } - if (valueTv.isVisible && shouldShowValueDetails()) { + if (valueTv.isVisible && shouldShowValueDetails(currentStatus)) { showValueDetails() } else { hideValueDetails() @@ -487,7 +498,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) } dateTv.text = transaction.transactionAt.fullDate() - feeLl.isVisible = shouldShowFee(transaction.status) + feeLl.isVisible = shouldShowFee(currentStatus) feeTv.text = "${transaction.displayFeeAmount()} ${transaction.displayFeeSymbol() ?: ""}" statusLl.isVisible = false @@ -508,7 +519,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) assetChangesContainer.setContent { AssetChangesList( - status = transaction.status, + status = transactionDetailState.value.status, senders = transaction.senders, receivers = transaction.receivers, fetchToken = { assetId -> @@ -521,7 +532,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) assetChangesLl.visibility = View.VISIBLE assetChangesContainer.setContent { AssetChangesList( - status = transaction.status, + status = transactionDetailState.value.status, senders = if (transaction.transactionType == TransactionType.TRANSFER_IN.value) emptyList() else transaction.senders, receivers = if (transaction.transactionType == TransactionType.TRANSFER_OUT.value) emptyList() else transaction.receivers, fetchToken = { assetId -> @@ -532,11 +543,11 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) } else { assetChangesLl.visibility = View.GONE } - if (transaction.status == TransactionStatus.PENDING.value) { + if (currentStatus == TransactionStatus.PENDING.value) { lifecycleScope.launch { - updateFeeVisibility(transaction.status) + updateFeeVisibility(currentStatus) if (transaction.chainId != Constants.ChainId.SOLANA_CHAIN_ID) { - updateActions() + updateActions(currentStatus) } } } @@ -556,7 +567,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) return pendingRawTx?.isGaslessPending() == false || transaction.displayFeeAmount().isNotEmpty() } - private suspend fun updateFeeVisibility(status: String = transaction.status) { + private suspend fun updateFeeVisibility(status: String) { val pendingRawTx: Web3RawTransaction? = if (status == TransactionStatus.PENDING.value) { web3ViewModel.getRawTransactionByHashAndChain(wallet.id, transaction.transactionHash, transaction.chainId) } else { @@ -565,7 +576,7 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) binding.feeLl.isVisible = shouldShowFee(status, pendingRawTx) } - private fun updateActions(status: String = transaction.status) { + private fun updateActions(status: String) { lifecycleScope.launch { binding.apply { if (status != TransactionStatus.PENDING.value) { @@ -691,6 +702,9 @@ class Web3TransactionFragment : BaseFragment(R.layout.fragment_web3_transaction) } private fun updateTransactionStatus(newStatus: String) { + val refreshedState = transactionDetailState.value.withStatus(newStatus) + transactionDetailState.value = refreshedState + bindMainValue(refreshedState) binding.apply { when (newStatus) { TransactionStatus.SUCCESS.value -> { diff --git a/app/src/test/java/one/mixin/android/web3/details/Web3TransactionDetailStateTest.kt b/app/src/test/java/one/mixin/android/web3/details/Web3TransactionDetailStateTest.kt new file mode 100644 index 0000000000..fdec946bab --- /dev/null +++ b/app/src/test/java/one/mixin/android/web3/details/Web3TransactionDetailStateTest.kt @@ -0,0 +1,35 @@ +package one.mixin.android.web3.details + +import one.mixin.android.db.web3.vo.TransactionStatus +import one.mixin.android.db.web3.vo.TransactionType +import kotlin.test.Test +import kotlin.test.assertEquals + +class Web3TransactionDetailStateTest { + @Test + fun refreshedSuccessUpdatesIncomingAmountTone() { + val pendingState = Web3TransactionDetailState( + status = TransactionStatus.PENDING.value, + transactionType = TransactionType.TRANSFER_IN.value, + ) + + assertEquals(Web3TransactionAmountTone.ASSIST, pendingState.amountTone) + + val refreshedState = pendingState.withStatus(TransactionStatus.SUCCESS.value) + + assertEquals(TransactionStatus.SUCCESS.value, refreshedState.status) + assertEquals(Web3TransactionAmountTone.INCOMING, refreshedState.amountTone) + } + + @Test + fun refreshedSuccessUpdatesOutgoingAmountTone() { + val pendingState = Web3TransactionDetailState( + status = TransactionStatus.PENDING.value, + transactionType = TransactionType.TRANSFER_OUT.value, + ) + + val refreshedState = pendingState.withStatus(TransactionStatus.SUCCESS.value) + + assertEquals(Web3TransactionAmountTone.OUTGOING, refreshedState.amountTone) + } +} From 1c51f7d61b00e40a96c4390fa0b0ee251784b87f Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 25 Aug 2026 10:23:46 +0800 Subject: [PATCH 4/5] feat(wallet): add X Layer filter tab to token list --- .../android/StringResourceFormatInstrumentedTest.kt | 1 + .../ui/wallet/TokenListBottomSheetDialogFragment.kt | 5 +++++ .../receive/Web3TokenListBottomSheetDialogFragment.kt | 5 +++++ .../swap/SwapTokenListBottomSheetDialogFragment.kt | 5 +++++ .../res/layout/fragment_asset_list_bottom_sheet.xml | 11 +++++++++++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 28 insertions(+) diff --git a/app/src/androidTest/java/one/mixin/android/StringResourceFormatInstrumentedTest.kt b/app/src/androidTest/java/one/mixin/android/StringResourceFormatInstrumentedTest.kt index 4fbcd36703..2ce28ca11c 100644 --- a/app/src/androidTest/java/one/mixin/android/StringResourceFormatInstrumentedTest.kt +++ b/app/src/androidTest/java/one/mixin/android/StringResourceFormatInstrumentedTest.kt @@ -165,6 +165,7 @@ class StringResourceFormatInstrumentedTest { R.string.Arbitrum to "Arbitrum", R.string.Avalanche to "Avalanche", R.string.HyperEVM to "HyperEVM", + R.string.XLayer to "X Layer", R.string.Toncoin to "TON", ) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt index 9a59c165b1..6b124c908a 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt @@ -29,6 +29,7 @@ import one.mixin.android.Constants.ChainId.Polygon import one.mixin.android.Constants.ChainId.SOLANA_CHAIN_ID import one.mixin.android.Constants.ChainId.TON_CHAIN_ID import one.mixin.android.Constants.ChainId.TRON_CHAIN_ID +import one.mixin.android.Constants.ChainId.XLayer import one.mixin.android.R import one.mixin.android.databinding.FragmentAssetListBottomSheetBinding import one.mixin.android.extension.addToList @@ -198,6 +199,10 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { HyperEVM } + R.id.radio_xlayer -> { + XLayer + } + else -> { null } diff --git a/app/src/main/java/one/mixin/android/web3/receive/Web3TokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/web3/receive/Web3TokenListBottomSheetDialogFragment.kt index e2c6ebd190..fbd1f3c7ba 100644 --- a/app/src/main/java/one/mixin/android/web3/receive/Web3TokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/receive/Web3TokenListBottomSheetDialogFragment.kt @@ -31,6 +31,7 @@ import one.mixin.android.Constants.ChainId.Optimism import one.mixin.android.Constants.ChainId.Polygon import one.mixin.android.Constants.ChainId.SOLANA_CHAIN_ID import one.mixin.android.Constants.ChainId.TON_CHAIN_ID +import one.mixin.android.Constants.ChainId.XLayer import one.mixin.android.Constants.Web3EvmChainIds import one.mixin.android.R import one.mixin.android.databinding.FragmentAssetListBottomSheetBinding @@ -165,6 +166,10 @@ class Web3TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() HyperEVM } + R.id.radio_xlayer -> { + XLayer + } + else -> { null } diff --git a/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt index 9010476f9a..377765d43d 100644 --- a/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt @@ -33,6 +33,7 @@ import one.mixin.android.Constants.ChainId.Optimism import one.mixin.android.Constants.ChainId.Polygon import one.mixin.android.Constants.ChainId.SOLANA_CHAIN_ID import one.mixin.android.Constants.ChainId.TON_CHAIN_ID +import one.mixin.android.Constants.ChainId.XLayer import one.mixin.android.Constants.ChainId.TRON_CHAIN_ID import one.mixin.android.R import one.mixin.android.api.handleMixinResponse @@ -223,6 +224,10 @@ class SwapTokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() HyperEVM } + R.id.radio_xlayer -> { + XLayer + } + R.id.radio_toncoin -> { TON_CHAIN_ID } diff --git a/app/src/main/res/layout/fragment_asset_list_bottom_sheet.xml b/app/src/main/res/layout/fragment_asset_list_bottom_sheet.xml index a74e049513..4ef4cc4fcd 100644 --- a/app/src/main/res/layout/fragment_asset_list_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_asset_list_bottom_sheet.xml @@ -249,6 +249,17 @@ android:text="@string/HyperEVM" android:textSize="14sp" android:textColor="@drawable/radio_button_text_selector" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 40b1104419..98538c27df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1578,6 +1578,7 @@ Arbitrum Avalanche HyperEVM + X Layer TON %1$s Account Account From 84a78156e14d2a918708b584df92d68ece2dbedd Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 25 Aug 2026 23:28:08 +0800 Subject: [PATCH 5/5] fix(swap): preserve supported wallet chains Treat the Ethereum address as capability for supported EVM chains while keeping Pearl gated by its dedicated address. --- .../ui/home/web3/trade/TradeFragment.kt | 19 ++-- .../SwapTokenListBottomSheetDialogFragment.kt | 4 +- .../web3/swap/SwapTokenSearchFilter.kt | 24 +++++ .../web3/swap/SwapTokenSearchFilterTest.kt | 88 +++++++++++++++++++ 4 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/web3/swap/SwapTokenSearchFilter.kt create mode 100644 app/src/test/java/one/mixin/android/web3/swap/SwapTokenSearchFilterTest.kt diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt index 80a7e19070..6a30445476 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt @@ -119,6 +119,7 @@ import one.mixin.android.web3.receive.Web3AddressFragment import one.mixin.android.web3.requiredSolBalance import one.mixin.android.web3.solanaRecipientAccountState import one.mixin.android.web3.swap.SwapTokenListBottomSheetDialogFragment +import one.mixin.android.web3.swap.filterSwapTokensByWalletChains import timber.log.Timber import java.math.BigDecimal import javax.inject.Inject @@ -1494,13 +1495,8 @@ class TradeFragment : BaseFragment() { return@requestRouteAPI true }, )?.let { remote: List -> - val filteredRemote: List = (if (chainIdSet == null) { - remote - } else { - remote.filter { token: SwapToken -> - chainIdSet.contains(token.chain.chainId) - } - }).filter { inMixin() || isWeb3TransferSupported(it.chain.chainId) } + val filteredRemote: List = + filterSwapTokensByWalletChains(remote, chainIdSet, inMixin()) stocks = filteredRemote.map { it.copy(isWeb3 = !inMixin(), walletId = walletId) }.map { token -> val t = web3tokens?.firstOrNull { web3Token -> (web3Token.assetKey == token.address && web3Token.assetId == token.assetId) @@ -1531,13 +1527,8 @@ class TradeFragment : BaseFragment() { return@requestRouteAPI true }, )?.let { remote: List -> - val filteredRemote: List = (if (chainIdSet == null) { - remote - } else { - remote.filter { token: SwapToken -> - chainIdSet.contains(token.chain.chainId) - } - }).filter { inMixin() || isWeb3TransferSupported(it.chain.chainId) } + val filteredRemote: List = + filterSwapTokensByWalletChains(remote, chainIdSet, inMixin()) if (!inMixin()) { remoteSwapTokens = filteredRemote.map { it.copy(isWeb3 = true, walletId = walletId) }.mapNotNull { token -> val local = swapViewModel.web3TokenItemById(walletId ?: "", token.assetId) diff --git a/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt index 377765d43d..5607878c05 100644 --- a/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt @@ -457,9 +457,9 @@ class SwapTokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() val chainIds = swapViewModel.getAddresses(Web3Signer.currentWalletId).map { it.chainId - } + }.toSet() this.filter { - it.chain.chainId in chainIds + isSwapSearchChainAvailable(it.chain.chainId, chainIds) } } else { this diff --git a/app/src/main/java/one/mixin/android/web3/swap/SwapTokenSearchFilter.kt b/app/src/main/java/one/mixin/android/web3/swap/SwapTokenSearchFilter.kt new file mode 100644 index 0000000000..d1f0f896cb --- /dev/null +++ b/app/src/main/java/one/mixin/android/web3/swap/SwapTokenSearchFilter.kt @@ -0,0 +1,24 @@ +package one.mixin.android.web3.swap + +import one.mixin.android.Constants +import one.mixin.android.api.response.web3.SwapToken +import one.mixin.android.db.web3.vo.isWeb3TransferSupported + +internal fun isSwapSearchChainAvailable( + chainId: String, + addressChainIds: Set, +): Boolean = + chainId in addressChainIds || + (chainId in Constants.Web3EvmChainIds && + Constants.ChainId.ETHEREUM_CHAIN_ID in addressChainIds) + +internal fun filterSwapTokensByWalletChains( + tokens: List, + addressChainIds: Set?, + inMixin: Boolean, +): List = + tokens.filter { token -> + inMixin || + (isWeb3TransferSupported(token.chain.chainId) && + (addressChainIds == null || isSwapSearchChainAvailable(token.chain.chainId, addressChainIds))) + } diff --git a/app/src/test/java/one/mixin/android/web3/swap/SwapTokenSearchFilterTest.kt b/app/src/test/java/one/mixin/android/web3/swap/SwapTokenSearchFilterTest.kt new file mode 100644 index 0000000000..62bcb3912e --- /dev/null +++ b/app/src/test/java/one/mixin/android/web3/swap/SwapTokenSearchFilterTest.kt @@ -0,0 +1,88 @@ +package one.mixin.android.web3.swap + +import one.mixin.android.Constants +import one.mixin.android.api.response.web3.SwapChain +import one.mixin.android.api.response.web3.SwapToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SwapTokenSearchFilterTest { + @Test + fun ethereumAddressMakesXLayerSearchable() { + assertTrue( + isSwapSearchChainAvailable( + chainId = Constants.ChainId.XLayer, + addressChainIds = setOf(Constants.ChainId.ETHEREUM_CHAIN_ID), + ), + ) + } + + @Test + fun pearlAddressMakesPearlSearchable() { + assertTrue( + isSwapSearchChainAvailable( + chainId = Constants.ChainId.PEARL_CHAIN_ID, + addressChainIds = setOf(Constants.ChainId.PEARL_CHAIN_ID), + ), + ) + } + + @Test + fun pearlRemainsHiddenWithoutPearlAddress() { + assertFalse( + isSwapSearchChainAvailable( + chainId = Constants.ChainId.PEARL_CHAIN_ID, + addressChainIds = setOf(Constants.ChainId.BITCOIN_CHAIN_ID), + ), + ) + } + + @Test + fun classicAndCommonWalletsKeepXLayerAndPearlWhenAddressesSupportThem() { + val tokens = listOf( + token("xlayer", Constants.ChainId.XLayer), + token("pearl", Constants.ChainId.PEARL_CHAIN_ID), + ) + + val result = filterSwapTokensByWalletChains( + tokens = tokens, + addressChainIds = setOf( + Constants.ChainId.ETHEREUM_CHAIN_ID, + Constants.ChainId.PEARL_CHAIN_ID, + ), + inMixin = false, + ) + + assertEquals(listOf("xlayer", "pearl"), result.map { it.assetId }) + } + + @Test + fun privacyWalletDoesNotApplyWeb3AddressChainFiltering() { + val tokens = listOf( + token("xlayer", Constants.ChainId.XLayer), + token("pearl", Constants.ChainId.PEARL_CHAIN_ID), + ) + + val result = filterSwapTokensByWalletChains( + tokens = tokens, + addressChainIds = null, + inMixin = true, + ) + + assertEquals(listOf("xlayer", "pearl"), result.map { it.assetId }) + } + + private fun token(assetId: String, chainId: String) = + SwapToken( + walletId = "wallet", + address = "", + assetId = assetId, + decimals = 8, + name = assetId, + symbol = assetId, + icon = "", + chain = SwapChain(chainId, chainId, chainId, ""), + ) +}