From 54e1d94397d3115b6b439a384669716d32e3d117 Mon Sep 17 00:00:00 2001 From: touge Date: Wed, 29 Jul 2026 16:54:43 +0800 Subject: [PATCH] Add Tron web3 support --- .../android/crypto/CryptoWalletHelper.kt | 34 +++++++ .../mixin/android/crypto/TipKeyGenerator.kt | 1 + .../mixin/android/crypto/TronKeyGenerator.kt | 93 +++++++++++++++++++ .../java/one/mixin/android/tip/bip44/Bip44.kt | 17 ++++ .../mixin/android/tip/wc/internal/Chain.kt | 5 + .../one/mixin/android/ui/home/MainActivity.kt | 5 +- .../BrowserWalletBottomSheetDialogFragment.kt | 33 ++++++- .../android/ui/wallet/ClassicWalletRequest.kt | 11 +++ .../ui/wallet/ReImportMnemonicFragment.kt | 75 ++++++++++++++- .../ui/wallet/components/FetchWalletPage.kt | 1 + .../wallet/viewmodel/FetchWalletViewModel.kt | 15 ++- .../one/mixin/android/ui/web/WebFragment.kt | 41 +++++++- .../mixin/android/web3/js/JsInjectorClient.kt | 1 + .../mixin/android/web3/js/JsSignMessage.kt | 7 +- .../one/mixin/android/web3/js/Web3Signer.kt | 33 ++++++- .../web3/receive/Web3AddressFragment.kt | 34 ++++--- .../Web3TokenListBottomSheetDialogFragment.kt | 12 ++- app/src/main/res/raw/init.js | 14 ++- .../android/crypto/TronKeyGeneratorTest.kt | 58 ++++++++++++ .../wallet/components/FetchWalletStateTest.kt | 1 + 20 files changed, 453 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/crypto/TronKeyGenerator.kt create mode 100644 app/src/test/java/one/mixin/android/crypto/TronKeyGeneratorTest.kt diff --git a/app/src/main/java/one/mixin/android/crypto/CryptoWalletHelper.kt b/app/src/main/java/one/mixin/android/crypto/CryptoWalletHelper.kt index db8f234a3a..79cdc24144 100644 --- a/app/src/main/java/one/mixin/android/crypto/CryptoWalletHelper.kt +++ b/app/src/main/java/one/mixin/android/crypto/CryptoWalletHelper.kt @@ -77,6 +77,10 @@ object CryptoWalletHelper { .removeSuffix("'/0'") .toIntOrNull() } + // Tron path: m/44'/195'/0'/0/{index} + path.startsWith("m/44'/195'/") -> { + path.removePrefix("m/44'/195'/0'/0/").toIntOrNull() + } // Bitcoin SegWit path: m/84'/0'/0'/0/{index} path.startsWith("m/84'/0'/") -> { path.removePrefix("m/84'/0'/0'/0/").toIntOrNull() @@ -162,6 +166,21 @@ object CryptoWalletHelper { } } + fun mnemonicToTronWallet(mnemonic: String, passphrase: String = "", index: Int = 0): CryptoWallet { + try { + val path = Bip44Path.tronPathString(index) + val privateKey = TronKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, passphrase, index) + return CryptoWallet( + mnemonic = mnemonic, + privateKey = Numeric.toHexString(privateKey), + address = TronKeyGenerator.privateKeyToAddress(privateKey), + path = path, + ) + } catch (e: Exception) { + throw RuntimeException("Tron wallet generation failed: ${e.message}", e) + } + } + fun mnemonicToEthereumWallet(mnemonic: String, passphrase: String = "", index: Int = 0): CryptoWallet { try { val path = Bip44Path.ethereumPathString(index) @@ -214,6 +233,10 @@ object CryptoWalletHelper { UtxoKeyGenerator.privateKeyToAddress(privateKey, chainId) } + Constants.ChainId.TRON_CHAIN_ID -> { + TronKeyGenerator.privateKeyToAddress(Numeric.hexStringToByteArray(privateKey)) + } + in Constants.Web3EvmChainIds -> { val privateKeyBytes: ByteArray = Numeric.hexStringToByteArray(privateKey) EthKeyGenerator.privateKeyToAddress(privateKeyBytes) @@ -240,6 +263,10 @@ object CryptoWalletHelper { Constants.ChainId.PEARL_CHAIN_ID -> { UtxoKeyGenerator.mnemonicToAddress(mnemonic, chainId, passphrase, index) } + Constants.ChainId.TRON_CHAIN_ID -> { + val privateKey = TronKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, passphrase, index) + TronKeyGenerator.privateKeyToAddress(privateKey) + } in Constants.Web3EvmChainIds -> { val privateKey: ByteArray = EthKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, passphrase, index) @@ -276,6 +303,7 @@ object CryptoWalletHelper { ) ECKey.fromPrivate(privateKey, true).getPrivateKeyEncoded(BitcoinNetwork.MAINNET).toBase58() } + Constants.ChainId.TRON_CHAIN_ID -> mnemonicToTronWallet(mnemonic, index = index).privateKey in Constants.Web3EvmChainIds -> mnemonicToEthereumWallet(mnemonic, index = index).privateKey else -> throw IllegalArgumentException("Unsupported chainId: $chainId") } @@ -373,6 +401,9 @@ object CryptoWalletHelper { Numeric.hexStringToByteArray(privateKeyStr) } } + Constants.ChainId.TRON_CHAIN_ID -> { + Numeric.hexStringToByteArray(privateKeyStr) + } in Constants.Web3EvmChainIds -> { Numeric.hexStringToByteArray(privateKeyStr) } @@ -448,6 +479,9 @@ object CryptoWalletHelper { index = derivationIndex, ) } + Constants.ChainId.TRON_CHAIN_ID -> { + TronKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, index = derivationIndex) + } in Constants.Web3EvmChainIds -> { EthKeyGenerator.getPrivateKeyFromMnemonic(mnemonic, index = derivationIndex) } diff --git a/app/src/main/java/one/mixin/android/crypto/TipKeyGenerator.kt b/app/src/main/java/one/mixin/android/crypto/TipKeyGenerator.kt index bdc1681820..82cdd507e5 100644 --- a/app/src/main/java/one/mixin/android/crypto/TipKeyGenerator.kt +++ b/app/src/main/java/one/mixin/android/crypto/TipKeyGenerator.kt @@ -17,6 +17,7 @@ internal object TipKeyGenerator { Constants.ChainId.BITCOIN_CHAIN_ID, Constants.ChainId.PEARL_CHAIN_ID -> UtxoKeyGenerator.deriveFromTipSeed(seed, chainId, index) Constants.ChainId.SOLANA_CHAIN_ID -> SolanaKeyGenerator.deriveFromTipSeed(seed, index) + Constants.ChainId.TRON_CHAIN_ID -> TronKeyGenerator.deriveFromTipSeed(seed, index) in Constants.Web3EvmChainIds -> EthKeyGenerator.deriveFromTipSeed(seed, index) else -> throw IllegalArgumentException("Not supported chainId") } diff --git a/app/src/main/java/one/mixin/android/crypto/TronKeyGenerator.kt b/app/src/main/java/one/mixin/android/crypto/TronKeyGenerator.kt new file mode 100644 index 0000000000..5b7511aa4f --- /dev/null +++ b/app/src/main/java/one/mixin/android/crypto/TronKeyGenerator.kt @@ -0,0 +1,93 @@ +package one.mixin.android.crypto + +import one.mixin.android.tip.bip44.Bip44Path +import one.mixin.android.tip.bip44.generateBip44Key +import one.mixin.android.util.encodeToBase58WithChecksum +import org.bitcoinj.crypto.MnemonicCode +import org.json.JSONArray +import org.json.JSONObject +import org.web3j.crypto.Bip32ECKeyPair +import org.web3j.crypto.ECKeyPair +import org.web3j.crypto.Hash +import org.web3j.crypto.MnemonicUtils +import org.web3j.crypto.Sign +import org.web3j.utils.Numeric +import java.nio.charset.StandardCharsets + +object TronKeyGenerator { + private const val ADDRESS_PREFIX = 0x41 + private const val MESSAGE_PREFIX = "\u0019TRON Signed Message:\n" + + fun getPrivateKeyFromMnemonic( + mnemonic: String, + passphrase: String = "", + index: Int = 0, + ): ByteArray { + val words = mnemonic.split(" ") + MnemonicCode.INSTANCE.check(words) + val seed = MnemonicUtils.generateSeed(mnemonic, passphrase) + val masterKeyPair = Bip32ECKeyPair.generateKeyPair(seed) + val keyPair = generateBip44Key(masterKeyPair, Bip44Path.tron(index)) + return Numeric.toBytesPadded(keyPair.privateKey, 32) + } + + fun privateKeyToAddress(privateKey: ByteArray): String { + return addressPayload(privateKey).encodeToBase58WithChecksum() + } + + internal fun deriveFromTipSeed(seed: ByteArray, index: Int): TipDerivedKey { + val masterKeyPair = Bip32ECKeyPair.generateKeyPair(seed) + val keyPair = generateBip44Key(masterKeyPair, Bip44Path.tron(index)) + val privateKey = Numeric.toBytesPadded(keyPair.privateKey, 32) + return TipDerivedKey(privateKey, privateKeyToAddress(privateKey)) + } + + fun signMessageV2(privateKey: ByteArray, message: String): String { + return signMessageV2(privateKey, message.toByteArray(StandardCharsets.UTF_8)) + } + + fun signMessageV2(privateKey: ByteArray, messageBytes: ByteArray): String { + val prefix = MESSAGE_PREFIX.toByteArray(StandardCharsets.UTF_8) + val size = messageBytes.size.toString().toByteArray(StandardCharsets.UTF_8) + return Numeric.toHexString(signDigest(privateKey, Hash.sha3(prefix + size + messageBytes))) + } + + fun signTransaction(privateKey: ByteArray, transactionJson: String): String { + val transaction = JSONObject(transactionJson) + val txId = Numeric.hexStringToByteArray(transaction.getString("txID")) + require(txId.size == 32) { "Tron txID must be 32 bytes" } + val contracts = transaction.getJSONObject("raw_data").getJSONArray("contract") + require(contracts.length() > 0) { "Tron transaction has no contract" } + val ownerAddress = contracts.getJSONObject(0) + .getJSONObject("parameter") + .getJSONObject("value") + .getString("owner_address") + val expectedHexAddress = Numeric.toHexStringNoPrefix(addressPayload(privateKey)) + require( + ownerAddress.equals(privateKeyToAddress(privateKey), ignoreCase = true) || + Numeric.cleanHexPrefix(ownerAddress).equals(expectedHexAddress, ignoreCase = true) + ) { "Tron transaction owner does not match the selected wallet" } + val signature = Numeric.toHexStringNoPrefix(signDigest(privateKey, txId)) + val signatures = transaction.optJSONArray("signature") ?: JSONArray().also { + transaction.put("signature", it) + } + if ((0 until signatures.length()).none { signatures.optString(it).equals(signature, ignoreCase = true) }) { + signatures.put(signature) + } + return transaction.toString() + } + + private fun addressPayload(privateKey: ByteArray): ByteArray { + require(privateKey.size == 32) { "Tron private key must be 32 bytes" } + val publicKey = Numeric.toBytesPadded(ECKeyPair.create(privateKey).publicKey, 64) + val hash = Hash.sha3(publicKey) + return byteArrayOf(ADDRESS_PREFIX.toByte()) + hash.copyOfRange(hash.size - 20, hash.size) + } + + private fun signDigest(privateKey: ByteArray, digest: ByteArray): ByteArray { + require(privateKey.size == 32) { "Tron private key must be 32 bytes" } + require(digest.size == 32) { "Tron digest must be 32 bytes" } + val signature = Sign.signMessage(digest, ECKeyPair.create(privateKey), false) + return signature.r + signature.s + signature.v + } +} diff --git a/app/src/main/java/one/mixin/android/tip/bip44/Bip44.kt b/app/src/main/java/one/mixin/android/tip/bip44/Bip44.kt index 982a6cd6d3..1634335fda 100644 --- a/app/src/main/java/one/mixin/android/tip/bip44/Bip44.kt +++ b/app/src/main/java/one/mixin/android/tip/bip44/Bip44.kt @@ -71,6 +71,19 @@ object Bip44Path { 0 or HARDENED_BIT, ) + /** + * Generate Tron derivation path with variable index + * Tron path: m/44'/195'/0'/0/{index} + */ + fun tron(index: Int = 0): IntArray = + intArrayOf( + 44 or HARDENED_BIT, + 195 or HARDENED_BIT, + 0 or HARDENED_BIT, + 0, + index, + ) + fun ethereumPathString(index: Int = 0): String { return "m/44'/60'/0'/0/$index" } @@ -79,6 +92,10 @@ object Bip44Path { return "m/44'/501'/${index}'/0'" } + fun tronPathString(index: Int = 0): String { + return "m/44'/195'/0'/0/$index" + } + fun bitcoinSegwitPathString(index: Int = 0): String { return "m/84'/0'/0'/0/$index" } 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..f3cb913902 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 @@ -5,6 +5,7 @@ import one.mixin.android.Constants import one.mixin.android.Constants.ChainId.BITCOIN_CHAIN_ID import one.mixin.android.Constants.ChainId.ETHEREUM_CHAIN_ID import one.mixin.android.Constants.ChainId.SOLANA_CHAIN_ID +import one.mixin.android.Constants.ChainId.TRON_CHAIN_ID import one.mixin.android.MixinApplication import one.mixin.android.extension.defaultSharedPreferences @@ -47,6 +48,8 @@ sealed class Chain( object Bitcoin : Chain(BITCOIN_CHAIN_ID, "bip122", "000000000019d6689c085ae165831e93", "000000000019d6689c085ae165831e93", "Bitcoin", "BTC", listOf("")) + object Tron : Chain(TRON_CHAIN_ID, "tron", "mainnet", "mainnet", "Tron", "TRX", listOf("https://api.trongrid.io")) + val chainId: String get() { return "$chainNamespace:$chainReference" @@ -75,6 +78,7 @@ sealed class Chain( HyperEVM -> Constants.ChainId.HyperEVM Solana -> Constants.ChainId.Solana Bitcoin -> BITCOIN_CHAIN_ID + Tron -> TRON_CHAIN_ID } } // Chain.Blast @@ -91,6 +95,7 @@ internal fun WalletConnectAddresses.accountFor(chain: Chain): String = when (chain) { Chain.Solana -> solana Chain.Bitcoin -> bitcoin + Chain.Tron -> "" else -> evm } diff --git a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt index a7f1ed3f88..d66ff0a48c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt @@ -905,9 +905,10 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba } private suspend fun initWalletConnect() { - if (!WalletConnect.isEnabled()) return try { - WalletConnectV2 + if (WalletConnect.isEnabled()) { + WalletConnectV2 + } val classicWalletId = web3Repository.getClassicWalletId() Web3Signer.init( { classicWalletId }, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt index 3ac176999d..2c65bf4f76 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/BrowserWalletBottomSheetDialogFragment.kt @@ -27,6 +27,7 @@ import one.mixin.android.R import one.mixin.android.api.request.web3.EstimateFeeRequest import one.mixin.android.api.response.web3.ParsedTx import one.mixin.android.api.response.web3.WalletOutput +import one.mixin.android.crypto.TronKeyGenerator import one.mixin.android.db.web3.vo.Web3TokenItem import one.mixin.android.db.web3.vo.getChainFromName import one.mixin.android.extension.base64Encode @@ -154,7 +155,7 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } private val isFeeWaived by lazy { requireArguments().getBoolean(ARGS_IS_FEE_FREE, false) } private val currentChain by lazy { - token?.getChainFromName() ?: Web3Signer.currentChain + if (signMessage.isTronMessage()) Chain.Tron else token?.getChainFromName() ?: Web3Signer.currentChain } private val utxoChainId: String get() = signMessage.utxoChainId?.takeIf { it in Constants.Web3UtxoChainIds } @@ -192,6 +193,7 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag if (address.isBlank()) { lifecycleScope.launch { address = when { + signMessage.isTronMessage() -> Web3Signer.tronAddress signMessage.type == JsSignMessage.TYPE_MESSAGE -> Web3Signer.address signMessage.isEvmMessage() -> Web3Signer.evmAddress signMessage.isSolMessage() -> Web3Signer.solanaAddress @@ -303,6 +305,12 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } private fun refreshEstimatedGasAndAsset(chain: Chain) { + if (signMessage.isTronMessage()) { + lifecycleScope.launch { + asset = viewModel.refreshAsset(Constants.ChainId.TRON_CHAIN_ID) + } + return + } if (signMessage.isGaslessTransfer()) { lifecycleScope.launch { asset = viewModel.refreshAsset(feeToken?.assetId ?: chain.getWeb3ChainId()) @@ -406,7 +414,21 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag } return@launch } - if (signMessage.type == JsSignMessage.TYPE_UTXO_TRANSACTION) { + if (signMessage.type == JsSignMessage.TYPE_TRON_TRANSACTION) { + val priv = viewModel.getWeb3Priv(requireContext(), pin, Constants.ChainId.TRON_CHAIN_ID) + val signedTransaction = TronKeyGenerator.signTransaction(priv, requireNotNull(signMessage.data)) + settleJsonSuccess(signedTransaction) + } else if (signMessage.type == JsSignMessage.TYPE_TRON_MESSAGE) { + val priv = viewModel.getWeb3Priv(requireContext(), pin, Constants.ChainId.TRON_CHAIN_ID) + require( + TronKeyGenerator.privateKeyToAddress(priv).equals(Web3Signer.tronAddress, ignoreCase = true) + ) { "Tron signing key does not match the selected wallet" } + val signature = TronKeyGenerator.signMessageV2( + priv, + signMessage.tronMessageBytes ?: requireNotNull(signMessage.data).toByteArray(Charsets.UTF_8), + ) + settleSuccess(signature) + } else if (signMessage.type == JsSignMessage.TYPE_UTXO_TRANSACTION) { val rawHex = signMessage.data ?: throw IllegalArgumentException("empty UTXO transaction hex") val chainId = utxoChainId val priv = viewModel.getWeb3Priv(requireContext(), pin, chainId) @@ -533,6 +555,7 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag private fun isAccountUnavailable(): Boolean = when { + signMessage.isTronMessage() -> Web3Signer.tronAddress.isBlank() signMessage.type == JsSignMessage.TYPE_MESSAGE -> Web3Signer.address.isBlank() signMessage.isSolMessage() || (signMessage.isGaslessTransfer() && currentChain == Chain.Solana) -> Web3Signer.solanaAddress.isBlank() signMessage.isEvmMessage() || (signMessage.isGaslessTransfer() && currentChain != Chain.Solana) -> Web3Signer.evmAddress.isBlank() @@ -545,6 +568,12 @@ class BrowserWalletBottomSheetDialogFragment : MixinComposeBottomSheetDialogFrag ) } + private fun settleJsonSuccess(result: String) { + settleRequest( + "mixinwallet.${Web3Signer.currentNetwork}.sendResponse(${signMessage.callbackId}, $result);", + ) + } + private fun settleError( code: Int, message: String, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/ClassicWalletRequest.kt b/app/src/main/java/one/mixin/android/ui/wallet/ClassicWalletRequest.kt index 02a2e51cd8..b11b9e5191 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/ClassicWalletRequest.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/ClassicWalletRequest.kt @@ -56,6 +56,7 @@ suspend fun buildClassicWalletRequest( val name = nextCommonWalletName(names) val evmAddress = privateKeyToAddress(spendKey, Constants.ChainId.ETHEREUM_CHAIN_ID, classicIndex) val solAddress = privateKeyToAddress(spendKey, Constants.ChainId.SOLANA_CHAIN_ID, classicIndex) + val tronAddress = privateKeyToAddress(spendKey, Constants.ChainId.TRON_CHAIN_ID, classicIndex) return WalletRequest( name = name, category = WalletCategory.CLASSIC.value, @@ -75,6 +76,13 @@ suspend fun buildClassicWalletRequest( privateKey = tipPrivToPrivateKey(spendKey, Constants.ChainId.SOLANA_CHAIN_ID, classicIndex), category = WalletCategory.CLASSIC.value, )) + add(createSignedWeb3AddressRequest( + destination = tronAddress, + chainId = Constants.ChainId.TRON_CHAIN_ID, + path = Bip44Path.tronPathString(classicIndex), + privateKey = tipPrivToPrivateKey(spendKey, Constants.ChainId.TRON_CHAIN_ID, classicIndex), + category = WalletCategory.CLASSIC.value, + )) } ) } @@ -195,6 +203,9 @@ fun createSignedWeb3AddressRequest( chainId == Constants.ChainId.BITCOIN_CHAIN_ID || chainId == Constants.ChainId.PEARL_CHAIN_ID -> { signUtxoAddressMessage(privateKey, message, chainId) } + chainId == Constants.ChainId.TRON_CHAIN_ID -> { + one.mixin.android.crypto.TronKeyGenerator.signMessageV2(privateKey, message) + } else -> null } } else { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/ReImportMnemonicFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/ReImportMnemonicFragment.kt index ada0e8a784..8e14a1ca0e 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/ReImportMnemonicFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/ReImportMnemonicFragment.kt @@ -52,6 +52,7 @@ class ReImportMnemonicFragment : BaseFragment(R.layout.fragment_compose) { private var solAddressInfo: Web3Address? = null private var btcAddressInfo: Web3Address? = null private var pearlAddressInfo: Web3Address? = null + private var tronAddressInfo: Web3Address? = null @Inject lateinit var web3Repository: Web3Repository @@ -84,6 +85,7 @@ class ReImportMnemonicFragment : BaseFragment(R.layout.fragment_compose) { solAddressInfo = viewModel.getAddressesByChainId(it, Constants.ChainId.SOLANA_CHAIN_ID) btcAddressInfo = viewModel.getAddressesByChainId(it, Constants.ChainId.BITCOIN_CHAIN_ID) pearlAddressInfo = viewModel.getAddressesByChainId(it, Constants.ChainId.PEARL_CHAIN_ID) + tronAddressInfo = viewModel.getAddressesByChainId(it, Constants.ChainId.TRON_CHAIN_ID) } } @@ -101,7 +103,7 @@ class ReImportMnemonicFragment : BaseFragment(R.layout.fragment_compose) { onComplete = { words -> lifecycleScope.launch { val saved = viewModel.saveWeb3PrivateKey(requireContext(), viewModel.getSpendKey()!!, walletId!!, words) - if (!saved || !ensureUtxoAddresses(walletId, words)) { + if (!saved || !ensureUtxoAddresses(walletId, words) || !ensureTronAddress(walletId, words)) { toast(R.string.Save_failure) return@launch } @@ -146,12 +148,18 @@ class ReImportMnemonicFragment : BaseFragment(R.layout.fragment_compose) { return getString(R.string.reimport_mnemonic_phrase_error) } } + tronAddressInfo?.let { + val derivedAddress = CryptoWalletHelper.mnemonicToAddress(mnemonicPhrase, Constants.ChainId.TRON_CHAIN_ID, "", requireNotNull(index)) + if (!derivedAddress.equals(it.destination, ignoreCase = true)) { + return getString(R.string.reimport_mnemonic_phrase_error) + } + } return null } private fun resolveDerivationIndex(): Int? { return CryptoWalletHelper.extractIndexFromPaths( - listOf(evmAddressInfo?.path, solAddressInfo?.path, btcAddressInfo?.path, pearlAddressInfo?.path) + listOf(evmAddressInfo?.path, solAddressInfo?.path, btcAddressInfo?.path, pearlAddressInfo?.path, tronAddressInfo?.path) ) } @@ -163,9 +171,10 @@ class ReImportMnemonicFragment : BaseFragment(R.layout.fragment_compose) { } val evmAddress: Web3Address? = viewModel.getAddressesByChainId(walletId, Constants.ChainId.ETHEREUM_CHAIN_ID) val solAddress: Web3Address? = viewModel.getAddressesByChainId(walletId, Constants.ChainId.SOLANA_CHAIN_ID) + val tronAddress: Web3Address? = viewModel.getAddressesByChainId(walletId, Constants.ChainId.TRON_CHAIN_ID) val derivationIndex = requireNotNull( CryptoWalletHelper.extractIndexFromPaths( - listOf(evmAddress?.path, solAddress?.path, btcAddress?.path, pearlAddress?.path) + listOf(evmAddress?.path, solAddress?.path, btcAddress?.path, pearlAddress?.path, tronAddress?.path) ) ) val mnemonicPhrase: String = mnemonic.joinToString(" ") @@ -215,6 +224,51 @@ class ReImportMnemonicFragment : BaseFragment(R.layout.fragment_compose) { return true } + private suspend fun ensureTronAddress(walletId: String, mnemonic: List): Boolean { + if (viewModel.getAddressesByChainId(walletId, Constants.ChainId.TRON_CHAIN_ID) != null) { + return true + } + val derivationIndex = requireNotNull( + CryptoWalletHelper.extractIndexFromPaths( + listOf( + viewModel.getAddressesByChainId(walletId, Constants.ChainId.ETHEREUM_CHAIN_ID)?.path, + viewModel.getAddressesByChainId(walletId, Constants.ChainId.SOLANA_CHAIN_ID)?.path, + viewModel.getAddressesByChainId(walletId, Constants.ChainId.BITCOIN_CHAIN_ID)?.path, + viewModel.getAddressesByChainId(walletId, Constants.ChainId.PEARL_CHAIN_ID)?.path, + ) + ) + ) + val derivedWallet = CryptoWalletHelper.mnemonicToTronWallet( + mnemonic.joinToString(" "), + index = derivationIndex, + ) + val request = createSignedWeb3AddressRequest( + destination = derivedWallet.address, + chainId = Constants.ChainId.TRON_CHAIN_ID, + path = Bip44Path.tronPathString(derivationIndex), + privateKey = derivedWallet.privateKey, + category = WalletCategory.IMPORTED_MNEMONIC.value, + ) + val response = web3Repository.updateWallet( + walletId, + WalletRequest(name = null, category = null, addresses = listOf(request)), + ) + if (!response.isSuccess) { + Timber.e("Failed to update Tron address walletId=$walletId errorCode=${response.errorCode} errorDescription=${response.errorDescription}") + return false + } + val updatedWallet = response.data ?: return false + val validatedAddress = + validateWalletAddressUpdateResponse(walletId, listOf(request), updatedWallet)?.singleOrNull() + ?: run { + Timber.e("Rejected mismatched Tron address update response walletId=$walletId") + return false + } + web3Repository.insertAddressList(listOf(validatedAddress)) + jobManager.addJobInBackground(RefreshSingleWalletJob(walletId)) + return true + } + private suspend fun validateMnemonicForOtherWallets(mnemonic: List) { val mnemonicPhrase: String = mnemonic.joinToString(" ") val currentSpendKey: ByteArray? = viewModel.getSpendKey() @@ -226,6 +280,7 @@ class ReImportMnemonicFragment : BaseFragment(R.layout.fragment_compose) { val solAddress: Web3Address? = viewModel.getAddressesByChainId(wallet.id, Constants.ChainId.SOLANA_CHAIN_ID) val btcAddress: Web3Address? = viewModel.getAddressesByChainId(wallet.id, Constants.ChainId.BITCOIN_CHAIN_ID) val pearlAddress: Web3Address? = viewModel.getAddressesByChainId(wallet.id, Constants.ChainId.PEARL_CHAIN_ID) + val tronAddress: Web3Address? = viewModel.getAddressesByChainId(wallet.id, Constants.ChainId.TRON_CHAIN_ID) val isEvmMatch: Boolean = evmAddress?.let { address: Web3Address -> val index: Int = requireNotNull(CryptoWalletHelper.extractIndexFromPath(address.path!!)) val derivedAddress: String = CryptoWalletHelper.mnemonicToAddress(mnemonicPhrase, Constants.ChainId.ETHEREUM_CHAIN_ID, "", index) @@ -246,12 +301,18 @@ class ReImportMnemonicFragment : BaseFragment(R.layout.fragment_compose) { val derivedAddress: String = CryptoWalletHelper.mnemonicToAddress(mnemonicPhrase, Constants.ChainId.PEARL_CHAIN_ID, "", index) derivedAddress.equals(address.destination, ignoreCase = true) } ?: false + val isTronMatch: Boolean = tronAddress?.let { address: Web3Address -> + val index: Int = requireNotNull(CryptoWalletHelper.extractIndexFromPath(address.path!!)) + val derivedAddress: String = CryptoWalletHelper.mnemonicToAddress(mnemonicPhrase, Constants.ChainId.TRON_CHAIN_ID, "", index) + derivedAddress.equals(address.destination, ignoreCase = true) + } ?: false val isWalletMatch: Boolean = (evmAddress == null || isEvmMatch) && (solAddress == null || isSolanaMatch) && (btcAddress == null || isBtcMatch) && (pearlAddress == null || isPearlMatch) && - (isEvmMatch || isSolanaMatch || isBtcMatch || isPearlMatch) + (tronAddress == null || isTronMatch) && + (isEvmMatch || isSolanaMatch || isBtcMatch || isPearlMatch || isTronMatch) if (!isWalletMatch) { return@forEach } @@ -269,6 +330,12 @@ class ReImportMnemonicFragment : BaseFragment(R.layout.fragment_compose) { if (!updated) { return@forEach } + val tronUpdated = runCatching { ensureTronAddress(wallet.id, mnemonic) } + .onFailure { Timber.e(it, "Failed to update Tron address for reimported wallet ${wallet.id}") } + .getOrDefault(false) + if (!tronUpdated) { + return@forEach + } RxBus.publish(WalletRefreshedEvent(wallet.id, WalletOperationType.CREATE)) Timber.e("Save wallet key: ${wallet.id}") return@forEach diff --git a/app/src/main/java/one/mixin/android/ui/wallet/components/FetchWalletPage.kt b/app/src/main/java/one/mixin/android/ui/wallet/components/FetchWalletPage.kt index 4355fa7f65..74feb3f25b 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/components/FetchWalletPage.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/components/FetchWalletPage.kt @@ -75,6 +75,7 @@ data class IndexedWallet( val ethereumWallet: CryptoWallet, val solanaWallet: CryptoWallet, val btcWallet: CryptoWallet, + val tronWallet: CryptoWallet, val pearlWallet: CryptoWallet? = null, val assets: List = emptyList(), val exists: Boolean diff --git a/app/src/main/java/one/mixin/android/ui/wallet/viewmodel/FetchWalletViewModel.kt b/app/src/main/java/one/mixin/android/ui/wallet/viewmodel/FetchWalletViewModel.kt index f4dd8fdcf7..ea4e30dc2c 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/viewmodel/FetchWalletViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/viewmodel/FetchWalletViewModel.kt @@ -189,6 +189,7 @@ class FetchWalletViewModel @Inject constructor( CryptoWalletHelper.mnemonicToSolanaWallet(mnemonic, index = index) val btcWallet = CryptoWalletHelper.mnemonicToBitcoinSegwitWallet(mnemonic, index = index) val pearlWallet = CryptoWalletHelper.mnemonicToPearlWallet(mnemonic, index = index) + val tronWallet = CryptoWalletHelper.mnemonicToTronWallet(mnemonic, index = index) val name = commonWalletName(requireNotNull(nextWalletNameIndex)) IndexedWallet( @@ -196,9 +197,10 @@ class FetchWalletViewModel @Inject constructor( ethereumWallet = ethereumWallet, solanaWallet = solanaWallet, btcWallet = btcWallet, + tronWallet = tronWallet, pearlWallet = pearlWallet, exists = web3Repository.anyAddressExists( - listOf(ethereumWallet.address, solanaWallet.address, btcWallet.address, pearlWallet.address) + listOf(ethereumWallet.address, solanaWallet.address, btcWallet.address, pearlWallet.address, tronWallet.address) ), ) } @@ -209,6 +211,7 @@ class FetchWalletViewModel @Inject constructor( it.solanaWallet.address, it.btcWallet.address, it.pearlWallet?.address, + it.tronWallet.address, ) } val response = web3Repository.searchAssetsByAddresses(addresses) @@ -232,7 +235,8 @@ class FetchWalletViewModel @Inject constructor( val btcTokens = tokensMap[wallet.btcWallet.address] ?: emptyList() val pearlTokens = wallet.pearlWallet?.let { tokensMap[it.address] } ?: emptyList() - val allTokens = (evmTokens + solanaTokens + btcTokens + pearlTokens).sortedByDescending { + val tronTokens = tokensMap[wallet.tronWallet.address] ?: emptyList() + val allTokens = (evmTokens + solanaTokens + btcTokens + pearlTokens + tronTokens).sortedByDescending { (it.priceUSD.toBigDecimalOrNull() ?: BigDecimal.ZERO) * (it.amount.toBigDecimalOrNull() ?: BigDecimal.ZERO) } wallet.copy(assets = allTokens) @@ -359,6 +363,13 @@ class FetchWalletViewModel @Inject constructor( path = it.solanaWallet.path, privateKey = it.solanaWallet.privateKey, category = category + ), + createSignedWeb3AddressRequest( + destination = it.tronWallet.address, + chainId = Constants.ChainId.TRON_CHAIN_ID, + path = it.tronWallet.path, + privateKey = it.tronWallet.privateKey, + category = category ) ) Pair(WalletRequest(it.name, category, addresses), it.solanaWallet.mnemonic.split(" ")) diff --git a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt index 10e9529023..11e390c234 100644 --- a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt @@ -1031,6 +1031,10 @@ class WebFragment : BaseFragment() { if (Web3Signer.evmAddress.isEmpty()) { toast(getString(R.string.not_support_network, network)) } + } else if (network.equals("tron", true)) { + if (Web3Signer.tronAddress.isEmpty()) { + toast(getString(R.string.not_support_network, network)) + } } else { return@launch } @@ -2057,10 +2061,10 @@ class WebFragment : BaseFragment() { try { val method = DAppMethod.fromValue(obj.getString("name")) val network = obj.getString("network") - if (network == Web3Signer.JsSignerNetwork.Solana.name) { - Web3Signer.useSolana() - } else { - Web3Signer.useEvm() + when (network) { + Web3Signer.JsSignerNetwork.Solana.name -> Web3Signer.useSolana() + Web3Signer.JsSignerNetwork.Tron.name -> Web3Signer.useTron() + else -> Web3Signer.useEvm() } if (isAddressEmpty(network, id)) { return @@ -2085,6 +2089,18 @@ class WebFragment : BaseFragment() { val o = obj.getJSONObject("object") if (network == Web3Signer.JsSignerNetwork.Solana.name) { signMessage(id, o.getString("data")) + } else if (network == Web3Signer.JsSignerNetwork.Tron.name) { + val tronData = o.get("data") + val bytes = tronMessageBytes(tronData) + val reviewData = if (tronData is String) tronData else tronData.toString() + onBrowserSign( + JsSignMessage( + id, + JsSignMessage.TYPE_TRON_MESSAGE, + data = reviewData, + tronMessageBytes = bytes, + ) + ) } else { signEvmMessage(id, o) } @@ -2099,6 +2115,11 @@ class WebFragment : BaseFragment() { } DAppMethod.SIGNTRANSACTION -> { + if (network == Web3Signer.JsSignerNetwork.Tron.name) { + val transaction = obj.getJSONObject("object").getJSONObject("transaction") + onBrowserSign(JsSignMessage(id, JsSignMessage.TYPE_TRON_TRANSACTION, data = transaction.toString())) + return + } val transaction = obj.getJSONObject("object") val to = transaction.getString("to") val from = transaction.getString("from") @@ -2196,6 +2217,18 @@ class WebFragment : BaseFragment() { onBrowserSign(JsSignMessage(callbackId, JsSignMessage.TYPE_MESSAGE, data = data)) } + private fun tronMessageBytes(data: Any): ByteArray = + when (data) { + is String -> data.toByteArray(Charsets.UTF_8) + is org.json.JSONArray -> ByteArray(data.length()) { index -> data.getInt(index).toByte() } + is JSONObject -> data.keys().asSequence() + .sortedBy { it.toInt() } + .map { data.getInt(it).toByte() } + .toList() + .toByteArray() + else -> throw IllegalArgumentException("Unsupported Tron message data") + } + private fun signEvmMessage( callbackId: Long, data: JSONObject, diff --git a/app/src/main/java/one/mixin/android/web3/js/JsInjectorClient.kt b/app/src/main/java/one/mixin/android/web3/js/JsInjectorClient.kt index aa02011241..370dff4398 100644 --- a/app/src/main/java/one/mixin/android/web3/js/JsInjectorClient.kt +++ b/app/src/main/java/one/mixin/android/web3/js/JsInjectorClient.kt @@ -16,6 +16,7 @@ class JsInjectorClient { Chain.Ethereum.rpcUrl, Web3Signer.evmAddress, Chain.Solana.rpcUrl, + Chain.Tron.rpcUrl, ) } diff --git a/app/src/main/java/one/mixin/android/web3/js/JsSignMessage.kt b/app/src/main/java/one/mixin/android/web3/js/JsSignMessage.kt index 13c752f0d7..31a2873163 100644 --- a/app/src/main/java/one/mixin/android/web3/js/JsSignMessage.kt +++ b/app/src/main/java/one/mixin/android/web3/js/JsSignMessage.kt @@ -17,6 +17,7 @@ class JsSignMessage( val type: Int, val wcEthereumTransaction: WCEthereumTransaction? = null, val data: String? = null, + val tronMessageBytes: ByteArray? = null, val solanaTxSource: SolanaTxSource = SolanaTxSource.InnerTransfer, val isSpeedUp: Boolean = false, val isCancelTx: Boolean = false, @@ -34,9 +35,12 @@ class JsSignMessage( const val TYPE_UTXO_TRANSACTION = 6 const val TYPE_GASLESS_TRANSFER = 7 + const val TYPE_TRON_MESSAGE = 8 + const val TYPE_TRON_TRANSACTION = 9 fun isSignMessage(type: Int): Boolean = - type == TYPE_MESSAGE || type == TYPE_TYPED_MESSAGE || type == TYPE_PERSONAL_MESSAGE || type == TYPE_SIGN_IN + type == TYPE_MESSAGE || type == TYPE_TYPED_MESSAGE || type == TYPE_PERSONAL_MESSAGE || + type == TYPE_SIGN_IN || type == TYPE_TRON_MESSAGE } val rate: BigDecimal? @@ -53,6 +57,7 @@ class JsSignMessage( fun isUtxoMessage() = type == TYPE_UTXO_TRANSACTION fun isSolMessage() = type == TYPE_RAW_TRANSACTION || type == TYPE_SIGN_IN fun isEvmMessage() = type == TYPE_TYPED_MESSAGE || type == TYPE_PERSONAL_MESSAGE || type == TYPE_TRANSACTION + fun isTronMessage() = type == TYPE_TRON_MESSAGE || type == TYPE_TRON_TRANSACTION fun isGaslessTransfer() = type == TYPE_GASLESS_TRANSFER val reviewData: String? 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..f05ac51a9b 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 @@ -7,6 +7,7 @@ import one.mixin.android.BuildConfig import one.mixin.android.Constants import one.mixin.android.Constants.ChainId.BITCOIN_CHAIN_ID import one.mixin.android.Constants.ChainId.SOLANA_CHAIN_ID +import one.mixin.android.Constants.ChainId.TRON_CHAIN_ID import one.mixin.android.MixinApplication import one.mixin.android.R import one.mixin.android.api.response.web3.EIP7702SignRequest @@ -64,6 +65,8 @@ object Web3Signer { data object Solana : JsSignerNetwork("solana") data object Pearl : JsSignerNetwork("pearl") + + data object Tron : JsSignerNetwork("tron") } private const val TAG = "Web3Signer" @@ -80,6 +83,7 @@ object Web3Signer { const val SOLANA_ADDRESS = "signer_solana_address" const val BTC_ADDRESS = "signer_btc_address" const val PEARL_ADDRESS = "signer_pearl_address" + const val TRON_ADDRESS = "signer_tron_address" const val PATH = "signer_path" const val CURRENT_WALLET_CATEGORY = "signer_current_wallet_category" const val CLASSIC_WALLET_ID = "signer_classic_wallet_id" @@ -97,6 +101,8 @@ object Web3Signer { private set var pearlAddress: String = "" private set + var tronAddress: String = "" + private set var path: String = "" private set var currentWalletId: String = "" @@ -108,7 +114,6 @@ object Web3Signer { var currentChain: Chain = Chain.Ethereum private set - // now only ETH and SOL var currentNetwork = JsSignerNetwork.Ethereum.name private set @@ -122,6 +127,7 @@ object Web3Signer { solanaAddress = sp.getString(Keys.SOLANA_ADDRESS, "") ?: "" btcAddress = sp.getString(Keys.BTC_ADDRESS, "") ?: "" pearlAddress = sp.getString(Keys.PEARL_ADDRESS, "") ?: "" + tronAddress = sp.getString(Keys.TRON_ADDRESS, "") ?: "" path = sp.getString(Keys.PATH, "") ?: "" currentWalletId = sp.getString(Keys.SELECTED_WEB3_WALLET_ID, "") ?: "" currentWalletCategory = sp.getString(Keys.CURRENT_WALLET_CATEGORY, WalletCategory.CLASSIC.value) @@ -129,7 +135,11 @@ object Web3Signer { classicWalletId = sp.getString(Keys.CLASSIC_WALLET_ID, "") ?: "" currentChain = findChainByHex(sp.getString(Keys.CURRENT_CHAIN, Chain.Ethereum.hexReference)) ?: Chain.Ethereum - currentNetwork = if (currentChain == Chain.Solana) JsSignerNetwork.Solana.name else JsSignerNetwork.Ethereum.name + currentNetwork = when (currentChain) { + Chain.Solana -> JsSignerNetwork.Solana.name + Chain.Tron -> JsSignerNetwork.Tron.name + else -> JsSignerNetwork.Ethereum.name + } } private fun persist() { @@ -138,6 +148,7 @@ object Web3Signer { sp.putString(Keys.SOLANA_ADDRESS, solanaAddress) sp.putString(Keys.BTC_ADDRESS, btcAddress) sp.putString(Keys.PEARL_ADDRESS, pearlAddress) + sp.putString(Keys.TRON_ADDRESS, tronAddress) sp.putString(Keys.PATH, path) sp.putString(Keys.SELECTED_WEB3_WALLET_ID, currentWalletId) sp.putString(Keys.CURRENT_WALLET_CATEGORY, currentWalletCategory) @@ -156,6 +167,7 @@ object Web3Signer { Chain.BinanceSmartChain.hexReference -> Chain.BinanceSmartChain Chain.HyperEVM.hexReference -> Chain.HyperEVM Chain.Solana.hexReference -> Chain.Solana + Chain.Tron.hexReference -> Chain.Tron else -> null } } @@ -167,6 +179,7 @@ object Web3Signer { when (network) { JsSignerNetwork.Solana.name -> solanaAddress = address JsSignerNetwork.Pearl.name -> pearlAddress = address + JsSignerNetwork.Tron.name -> tronAddress = address else -> evmAddress = address } persist() @@ -188,6 +201,13 @@ object Web3Signer { persist() } + fun useTron() { + address = tronAddress + currentChain = Chain.Tron + currentNetwork = JsSignerNetwork.Tron.name + persist() + } + suspend fun init(classicQuery: () -> String?, queryAddress: (String) -> List, queryWallet: (String) -> WalletItem?) = walletSelectionMutex.withLock { classicWalletId = PropertyHelper.findValueByKey(Keys.CLASSIC_WALLET_ID, classicQuery() ?: "") @@ -244,6 +264,7 @@ object Web3Signer { path = addresses.firstOrNull { it.chainId in Constants.Web3ChainIds || it.chainId == SOLANA_CHAIN_ID || + it.chainId == TRON_CHAIN_ID || it.chainId in Constants.Web3UtxoChainIds }?.path ?: "" evmAddress = @@ -254,12 +275,14 @@ object Web3Signer { ?: "" btcAddress = addresses.firstOrNull {it.chainId == BITCOIN_CHAIN_ID}?.destination ?:"" pearlAddress = addresses.firstOrNull { it.chainId == Constants.ChainId.PEARL_CHAIN_ID }?.destination ?: "" + tronAddress = addresses.firstOrNull { it.chainId == TRON_CHAIN_ID }?.destination ?: "" address = evmAddress } else { evmAddress = "" solanaAddress = "" btcAddress = "" pearlAddress = "" + tronAddress = "" address = "" path = "" } @@ -318,6 +341,12 @@ object Web3Signer { persist() Result.success(Chain.Solana.name) } + Chain.Tron.hexReference -> { + currentChain = Chain.Tron + currentNetwork = JsSignerNetwork.Tron.name + persist() + Result.success(Chain.Tron.name) + } else -> { Result.failure(IllegalArgumentException("No support")) } diff --git a/app/src/main/java/one/mixin/android/web3/receive/Web3AddressFragment.kt b/app/src/main/java/one/mixin/android/web3/receive/Web3AddressFragment.kt index 3cac98e62b..0dc449d58d 100644 --- a/app/src/main/java/one/mixin/android/web3/receive/Web3AddressFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/receive/Web3AddressFragment.kt @@ -26,6 +26,7 @@ import one.mixin.android.Constants.AssetId.USDT_ASSET_BEP_ID import one.mixin.android.Constants.AssetId.USDT_ASSET_ETH_ID import one.mixin.android.Constants.AssetId.USDT_ASSET_POL_ID import one.mixin.android.Constants.AssetId.USDT_ASSET_SOL_ID +import one.mixin.android.Constants.AssetId.USDT_ASSET_TRC_ID import one.mixin.android.R import one.mixin.android.compose.InputAmountBottomSheetDialogFragment import one.mixin.android.databinding.FragmentWeb3AddressBinding @@ -155,27 +156,23 @@ class Web3AddressFragment : BaseFragment() { if (!hideNetworkSwitch) { if (Constants.AssetId.usdtAssets.containsKey(web3Token.assetId)) { binding.networkChipGroup.isVisible = true - initChips( - if (Web3Signer.evmAddress.isBlank()) { - mapOf( - USDT_ASSET_SOL_ID to "Solana", + initChips(buildMap { + if (Web3Signer.evmAddress.isNotBlank()) { + putAll( + mapOf( + USDT_ASSET_ETH_ID to "Ethereum", + USDT_ASSET_POL_ID to "Polygon", + USDT_ASSET_BEP_ID to "BSC", ) - } else if (Web3Signer.solanaAddress.isBlank()) { - mapOf( - USDT_ASSET_ETH_ID to "Ethereum", - USDT_ASSET_POL_ID to "Polygon", - USDT_ASSET_BEP_ID to "BSC", - ) - - } else { - mapOf( - USDT_ASSET_ETH_ID to "Ethereum", - USDT_ASSET_SOL_ID to "Solana", - USDT_ASSET_POL_ID to "Polygon", - USDT_ASSET_BEP_ID to "BSC", ) } - ) + if (Web3Signer.solanaAddress.isNotBlank()) { + put(USDT_ASSET_SOL_ID, "Solana") + } + if (Web3Signer.tronAddress.isNotBlank()) { + put(USDT_ASSET_TRC_ID, "TRON") + } + }) } else if (Constants.AssetId.usdcAssets.containsKey(web3Token.assetId)) { initChips( if (Web3Signer.evmAddress.isBlank()) { @@ -238,6 +235,7 @@ class Web3AddressFragment : BaseFragment() { Constants.ChainId.SOLANA_CHAIN_ID -> Web3Signer.solanaAddress Constants.ChainId.BITCOIN_CHAIN_ID -> Web3Signer.btcAddress Constants.ChainId.PEARL_CHAIN_ID -> Web3Signer.pearlAddress + Constants.ChainId.TRON_CHAIN_ID -> Web3Signer.tronAddress else -> Web3Signer.evmAddress } updateUI() 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..2d5cf0fd20 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.TRON_CHAIN_ID import one.mixin.android.Constants.Web3EvmChainIds import one.mixin.android.R import one.mixin.android.databinding.FragmentAssetListBottomSheetBinding @@ -113,7 +114,7 @@ class Web3TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() radioAll.isVisible = true radioBtc.isVisible = true radioEth.isVisible = true - radioTron.isVisible = false + radioTron.isVisible = true radioToncoin.isVisible = false radioBase.isVisible = true radioBsc.isVisible = true @@ -133,6 +134,10 @@ class Web3TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() SOLANA_CHAIN_ID } + R.id.radio_tron -> { + TRON_CHAIN_ID + } + R.id.radio_base -> { Base } @@ -379,7 +384,10 @@ class Web3TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() binding.pb.isVisible = true val fuzzyResults = bottomViewModel.queryAsset(walletId = walletId, query = query, web3 = true) val remoteAssets = fuzzyResults.filter { - it.chainId in Web3EvmChainIds || it.chainId == SOLANA_CHAIN_ID || it.chainId in Constants.Web3UtxoChainIds + it.chainId in Web3EvmChainIds || + it.chainId == SOLANA_CHAIN_ID || + it.chainId == TRON_CHAIN_ID || + it.chainId in Constants.Web3UtxoChainIds }.map { item -> bottomViewModel.web3TokenItemById(walletId ?: "", item.assetId).let { local -> if (local != null && (local.level >= 10 || local.hidden == false)) { diff --git a/app/src/main/res/raw/init.js b/app/src/main/res/raw/init.js index cc755d6f00..0b0218e546 100644 --- a/app/src/main/res/raw/init.js +++ b/app/src/main/res/raw/init.js @@ -9,6 +9,9 @@ solana: { cluster: "%4$s", }, + tron: { + nodeURL: "%5$s", + }, isDebug: false }; const core = mixinwallet.core('CALLBACK', (params) => { @@ -16,8 +19,9 @@ }); const ethereum = mixinwallet.ethereum(config.ethereum); const solana = mixinwallet.solana(config.solana); + const tron = mixinwallet.tron(config.tron); - core.registerProviders([ethereum, solana]); + core.registerProviders([ethereum, solana, tron]); ethereum.emitChainChanged = (chainId) => { const normalizedChainId = '0x' + parseInt(chainId || '1').toString(16); @@ -50,10 +54,18 @@ writable: false, value: solana, }, + tron: { + configurable: false, + enumerable: true, + writable: false, + value: tron, + }, }); window.ethereum = ethereum; window.solana = solana; + window.tronLink = tron; + window.tronWeb = tron.tronWeb; const mixinLogoDataUrl = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0wIDExMkMwIDUwLjE0NDEgNTAuMTQ0MSAwIDExMiAwSDQwMEM0NjEuODU2IDAgNTEyIDUwLjE0NDEgNTEyIDExMlY0MDBDNTEyIDQ2MS44NTYgNDYxLjg1NiA1MTIgNDAwIDUxMkgxMTJDNTAuMTQ0MSA1MTIgMCA0NjEuODU2IDAgNDAwVjExMloiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl8yNzk1XzE3KSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTM4MS43NDkgMTU2QzM4My43NTggMTU2IDM4NS4zOTMgMTU3LjU5OCAzODUuNDU0IDE1OS41OTJMMzg1LjQ1NiAxNTkuNzA3VjM1MS4yOTJDMzg1LjQ1NiAzNTEuODQyIDM4NS4zMzMgMzUyLjM4NiAzODUuMDk3IDM1Mi44ODNDMzg0LjIzNiAzNTQuNjk2IDM4Mi4wOTQgMzU1LjQ4OCAzODAuMjY4IDM1NC42OUwzODAuMTU4IDM1NC42NEwzMzIuNjM0IDMzMi4wNTZDMzMwLjQyIDMzMS4wMDMgMzI4Ljk5MiAzMjguNzk1IDMyOC45MzMgMzI2LjM1M0wzMjguOTMxIDMyNi4xOTZWMTgxLjUzOEMzMjguOTMxIDE3OC45NDkgMzMwLjQ3IDE3Ni42MTMgMzMyLjgzNiAxNzUuNTg3TDMzMi45NzYgMTc1LjUyOEwzODAuMzU0IDE1Ni4yNzNDMzgwLjc5NyAxNTYuMDkzIDM4MS4yNzEgMTU2IDM4MS43NDkgMTU2Wk0xMjkuNzA3IDE1NkMxMzAuMTg1IDE1NiAxMzAuNjU5IDE1Ni4wOTMgMTMxLjEwMiAxNTYuMjczTDE3OC40OCAxNzUuNTI4QzE4MC45MjUgMTc2LjUyMiAxODIuNTI0IDE3OC44OTggMTgyLjUyNCAxODEuNTM4VjMyNi4xOTZDMTgyLjUyNCAzMjguNyAxODEuMDgzIDMzMC45ODEgMTc4LjgyMiAzMzIuMDU2TDEzMS4yOTcgMzU0LjY0QzEyOS40NDggMzU1LjUxOSAxMjcuMjM3IDM1NC43MzIgMTI2LjM1OSAzNTIuODgzQzEyNi4xMjMgMzUyLjM4NiAxMjYgMzUxLjg0MiAxMjYgMzUxLjI5MlYxNTkuNzA3QzEyNiAxNTcuNjYgMTI3LjY1OSAxNTYgMTI5LjcwNyAxNTZaTTI1OS44OTggMTk3Ljg0N0wzMDMuNzE5IDIyMy4xNTFDMzA2LjMgMjI0LjY0MSAzMDcuODg5IDIyNy4zOTUgMzA3Ljg4OSAyMzAuMzc1VjI4MC45ODJDMzA3Ljg4OSAyODMuOTYyIDMwNi4zIDI4Ni43MTYgMzAzLjcxOSAyODguMjA2TDI1OS44OTggMzEzLjUxQzI1Ny4zMTcgMzE0Ljk5OSAyNTQuMTM4IDMxNC45OTkgMjUxLjU1OCAzMTMuNTFMMjA3LjczNiAyODguMjA2QzIwNS4xNTYgMjg2LjcxNiAyMDMuNTY2IDI4My45NjIgMjAzLjU2NiAyODAuOTgyVjIzMC4zNzVDMjAzLjU2NiAyMjcuMzk1IDIwNS4xNTYgMjI0LjY0MSAyMDcuNzM2IDIyMy4xNTFMMjUxLjU1OCAxOTcuODQ3QzI1NC4xMzggMTk2LjM1NyAyNTcuMzE3IDE5Ni4zNTcgMjU5Ljg5OCAxOTcuODQ3WiIgZmlsbD0id2hpdGUiLz4KPGRlZnM+CjxsaW5lYXJHcmFkaWVudCBpZD0icGFpbnQwX2xpbmVhcl8yNzk1XzE3IiB4MT0iMTMuNSIgeTE9IjUxMiIgeDI9IjUxMiIgeTI9Ii0xLjYxODczZS0wNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjMkE1QkY2Ii8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzUyOUZGOSIvPgo8L2xpbmVhckdyYWRpZW50Pgo8L2RlZnM+Cjwvc3ZnPgo='; const info = { diff --git a/app/src/test/java/one/mixin/android/crypto/TronKeyGeneratorTest.kt b/app/src/test/java/one/mixin/android/crypto/TronKeyGeneratorTest.kt new file mode 100644 index 0000000000..b718bf86de --- /dev/null +++ b/app/src/test/java/one/mixin/android/crypto/TronKeyGeneratorTest.kt @@ -0,0 +1,58 @@ +package one.mixin.android.crypto + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.web3j.utils.Numeric + +class TronKeyGeneratorTest { + private val privateKey = Numeric.hexStringToByteArray( + "0000000000000000000000000000000000000000000000000000000000000001" + ) + + @Test + fun privateKeyProducesTronBase58CheckAddress() { + assertEquals( + "TMVQGm1qAQYVdetCeGRRkTWYYrLXuHK2HC", + TronKeyGenerator.privateKeyToAddress(privateKey), + ) + } + + @Test + fun mnemonicUsesTronBip44Path() { + val mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + val derived = TronKeyGenerator.getPrivateKeyFromMnemonic(mnemonic) + assertEquals( + "b5a4cea271ff424d7c31dc12a3e43e401df7a40d7412a15750f3f0b6b5449a28", + Numeric.toHexStringNoPrefix(derived), + ) + assertEquals("TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH", TronKeyGenerator.privateKeyToAddress(derived)) + } + + @Test + fun messageV2MatchesTronWeb() { + assertEquals( + "0x1eb45a12a27653b832e450d0fc72b86e70b899fe7bcd2c573144f96d8c84aec72ad5057fa7ab80054ee06851b8209ce1957b45f6668c753247976cecad05109c1b", + TronKeyGenerator.signMessageV2(privateKey, "hello"), + ) + } + + @Test + fun transactionSignatureMatchesTronWeb() { + val txId = "0000000000000000000000000000000000000000000000000000000000000001" + val signed = JSONObject( + TronKeyGenerator.signTransaction( + privateKey, + """{"txID":"$txId","raw_data":{"contract":[{"parameter":{"value":{"owner_address":"417e5f4552091a69125d5dfcb7b8c2659029395bdf"}}}]}}""", + ) + ) + assertEquals(txId, signed.getString("txID")) + assertTrue( + signed.getJSONArray("signature").getString(0).equals( + "6673ffad2147741f04772b6f921f0ba6af0c1e77fc439e65c36dedf4092e88984c1a971652e0ada880120ef8025e709fff2080c4a39aae068d12eed009b68c891C", + ignoreCase = true, + ) + ) + } +} diff --git a/app/src/test/java/one/mixin/android/ui/wallet/components/FetchWalletStateTest.kt b/app/src/test/java/one/mixin/android/ui/wallet/components/FetchWalletStateTest.kt index 5af0dbd68a..55a1180b10 100644 --- a/app/src/test/java/one/mixin/android/ui/wallet/components/FetchWalletStateTest.kt +++ b/app/src/test/java/one/mixin/android/ui/wallet/components/FetchWalletStateTest.kt @@ -58,6 +58,7 @@ class FetchWalletStateTest { ethereumWallet = wallet, solanaWallet = wallet, btcWallet = wallet, + tronWallet = wallet, exists = exists, ) }