diff --git a/README.md b/README.md index e8360cc8..d1bd8ca9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Next generation mobile wallet for Pezkuwichain and the Polkadot ecosystem. -[![](https://img.shields.io/twitter/follow/pezkuwichain?label=Follow&style=social)](https://twitter.com/pezkuwichain) +[![](https://img.shields.io/twitter/follow/pezkuwichain?label=Follow&style=social)](https://x.com/bizinikiwi) ## About @@ -105,8 +105,8 @@ WALLET_CONNECT_PROJECT_ID=mock - Website: https://pezkuwichain.io - Documentation: https://docs.pezkuwichain.io -- Telegram: https://t.me/pezkuwichain -- Twitter: https://twitter.com/pezkuwichain +- Telegram: https://t.me/kurdishmedya +- Twitter: https://x.com/bizinikiwi - GitHub: https://github.com/pezkuwichain ## License diff --git a/common/build.gradle b/common/build.gradle index 0d9e8487..903043b4 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -9,7 +9,7 @@ android { buildConfigField "String", "TERMS_URL", "\"https://pezkuwichain.io/terms.html\"" buildConfigField "String", "GITHUB_URL", "\"https://github.com/pezkuwichain\"" buildConfigField "String", "TELEGRAM_URL", "\"https://t.me/pezkuwichainBot\"" - buildConfigField "String", "TWITTER_URL", "\"https://twitter.com/pezkuwichain\"" + buildConfigField "String", "TWITTER_URL", "\"https://x.com/bizinikiwi\"" buildConfigField "String", "RATE_URL", "\"market://details?id=${rootProject.applicationId}.${releaseApplicationSuffix}\"" buildConfigField "String", "EMAIL", "\"support@pezkuwichain.io\"" buildConfigField "String", "YOUTUBE_URL", "\"https://www.youtube.com/@SatoshiQazi\"" diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt index cae900ea..c0dd818e 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt @@ -25,6 +25,9 @@ object MetaAccountSecrets : Schema() { val EthereumKeypair by schema(KeyPairSchema).optional() val EthereumDerivationPath by string().optional() + + val TronKeypair by schema(KeyPairSchema).optional() + val TronDerivationPath by string().optional() } object ChainAccountSecrets : Schema() { @@ -42,6 +45,8 @@ fun MetaAccountSecrets( substrateDerivationPath: String? = null, ethereumKeypair: Keypair? = null, ethereumDerivationPath: String? = null, + tronKeypair: Keypair? = null, + tronDerivationPath: String? = null, ): EncodableStruct = MetaAccountSecrets { secrets -> secrets[Entropy] = entropy secrets[SubstrateSeed] = substrateSeed @@ -61,6 +66,15 @@ fun MetaAccountSecrets( } } secrets[EthereumDerivationPath] = ethereumDerivationPath + + secrets[TronKeypair] = tronKeypair?.let { + KeyPairSchema { keypair -> + keypair[PublicKey] = it.publicKey + keypair[PrivateKey] = it.privateKey + keypair[Nonce] = null // tron uses secp256k1 (like ethereum), so nonce is always null + } + } + secrets[TronDerivationPath] = tronDerivationPath } fun ChainAccountSecrets( @@ -86,6 +100,9 @@ val EncodableStruct.substrateDerivationPath val EncodableStruct.ethereumDerivationPath get() = get(MetaAccountSecrets.EthereumDerivationPath) +val EncodableStruct.tronDerivationPath + get() = get(MetaAccountSecrets.TronDerivationPath) + val EncodableStruct.entropy get() = get(MetaAccountSecrets.Entropy) @@ -98,6 +115,9 @@ val EncodableStruct.substrateKeypair val EncodableStruct.ethereumKeypair get() = get(MetaAccountSecrets.EthereumKeypair) +val EncodableStruct.tronKeypair + get() = get(MetaAccountSecrets.TronKeypair) + val EncodableStruct.derivationPath get() = get(ChainAccountSecrets.DerivationPath) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt b/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt new file mode 100644 index 00000000..3de6e08e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt @@ -0,0 +1,115 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +/** + * Tron address format: + * Base58Check(0x41 ++ accountId), where `accountId` is the 20-byte keccak256-derived id + * (the exact same derivation Ethereum uses: keccak256(uncompressed pubkey without the 0x04 prefix)[-20:]). + * + * We deliberately reuse the existing Ethereum-style pubkey -> accountId derivation from `substrate_sdk_android` + * (`asEthereumPublicKey().toAccountId()`) instead of re-implementing keccak/secp256k1 ourselves, since that math + * is identical for Tron - only the final string encoding differs (Base58Check with a 0x41 prefix, instead of + * checksummed hex with a 0x prefix). + * + * Base58Check itself (this file's [Base58]/[Base58Check] objects) is hand-implemented since no Base58 library is + * currently on this project's classpath. It is a deterministic text encoding (not a secret-dependent cryptographic + * primitive) and has been cross-checked against the well-known USDT-TRC20 contract address + * (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t <-> hex 41a614f803b6fd780986a42c78ec9c7f77e6ded13c) - see [TronAddressTest]. + */ +private const val TRON_ADDRESS_PREFIX_BYTE: Byte = 0x41 + +object Base58 { + + private const val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + private val BASE = BigInteger.valueOf(58) + + fun encode(input: ByteArray): String { + if (input.isEmpty()) return "" + + var value = BigInteger(1, input) + + val sb = StringBuilder() + while (value > BigInteger.ZERO) { + val (div, rem) = value.divideAndRemainder(BASE) + sb.append(ALPHABET[rem.toInt()]) + value = div + } + + // Every leading zero byte of the input must be represented as a leading '1' in the output, + // since a plain big-integer encoding would otherwise drop them. + val leadingZeroBytes = input.takeWhile { it == 0.toByte() }.size + repeat(leadingZeroBytes) { sb.append(ALPHABET[0]) } + + return sb.reverse().toString() + } + + fun decode(input: String): ByteArray { + if (input.isEmpty()) return ByteArray(0) + + var value = BigInteger.ZERO + for (char in input) { + val digit = ALPHABET.indexOf(char) + require(digit >= 0) { "Invalid Base58 character: '$char'" } + + value = value.multiply(BASE).add(BigInteger.valueOf(digit.toLong())) + } + + var bytes = if (value == BigInteger.ZERO) ByteArray(0) else value.toByteArray() + // BigInteger#toByteArray may prepend a single zero sign byte for an otherwise-positive value; drop it. + if (bytes.size > 1 && bytes[0] == 0.toByte()) { + bytes = bytes.copyOfRange(1, bytes.size) + } + + val leadingOnes = input.takeWhile { it == ALPHABET[0] }.length + + return ByteArray(leadingOnes) + bytes + } +} + +object Base58Check { + + private const val CHECKSUM_SIZE = 4 + + fun encode(payload: ByteArray): String { + val checksum = payload.sha256().sha256().copyOfRange(0, CHECKSUM_SIZE) + + return Base58.encode(payload + checksum) + } + + fun decode(input: String): ByteArray { + val full = Base58.decode(input) + require(full.size > CHECKSUM_SIZE) { "Base58Check payload too short: $input" } + + val payload = full.copyOfRange(0, full.size - CHECKSUM_SIZE) + val checksum = full.copyOfRange(full.size - CHECKSUM_SIZE, full.size) + + val expectedChecksum = payload.sha256().sha256().copyOfRange(0, CHECKSUM_SIZE) + require(checksum.contentEquals(expectedChecksum)) { "Invalid Base58Check checksum: $input" } + + return payload + } +} + +// See the file-level doc above for why it's correct to reuse the Ethereum pubkey->accountId derivation here. +fun ByteArray.tronPublicKeyToAccountId(): AccountId = asEthereumPublicKey().toAccountId().value + +fun AccountId.toTronAddress(): String { + require(size == 20) { "Tron account id must be 20 bytes, got $size" } + + return Base58Check.encode(byteArrayOf(TRON_ADDRESS_PREFIX_BYTE) + this) +} + +fun String.tronAddressToAccountId(): AccountId { + val decoded = Base58Check.decode(this) + require(decoded.size == 21 && decoded[0] == TRON_ADDRESS_PREFIX_BYTE) { "Not a valid Tron address: $this" } + + return decoded.copyOfRange(1, decoded.size) +} + +fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess + +fun emptyTronAccountId() = ByteArray(20) { 1 } diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt new file mode 100644 index 00000000..d036c57f --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TronAddressTest { + + /** + * TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t is the well-known Tron mainnet USDT (TRC-20) contract address + * (the same one used in this app's Tron chain config). Its raw 21-byte Base58Check payload + * (0x41 prefix ++ 20-byte account id) is publicly documented as + * 41a614f803b6fd780986a42c78ec9c7f77e6ded13c - this is an independently-verifiable, real-world + * test vector (not a value invented for this test). + */ + private val knownTronAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + private val knownTronAddressHex = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" + + @Test + fun `should decode known Tron address to expected raw bytes`() { + val decodedPayload = Base58Check.decode(knownTronAddress) + + assertEquals(knownTronAddressHex, decodedPayload.toHexString(withPrefix = false)) + } + + @Test + fun `should re-encode known raw bytes back to the exact known Tron address`() { + val payload = knownTronAddressHex.fromHex() + + assertEquals(knownTronAddress, Base58Check.encode(payload)) + } + + @Test + fun `accountId to address and back should round trip`() { + val accountId = knownTronAddressHex.fromHex().copyOfRange(1, 21) + + val address = accountId.toTronAddress() + assertEquals(knownTronAddress, address) + + val decodedBack = address.tronAddressToAccountId() + assertTrue(decodedBack.contentEquals(accountId)) + } + + @Test + fun `isValidTronAddress should accept known good address`() { + assertTrue(knownTronAddress.isValidTronAddress()) + } + + @Test + fun `isValidTronAddress should reject corrupted address`() { + val corrupted = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6u" // last char changed -> checksum mismatch + assertFalse(corrupted.isValidTronAddress()) + } + + @Test + fun `isValidTronAddress should reject a plain Ethereum-style address`() { + assertFalse("0x9858EfFD232B4033E47d90003D41EC34EcaEda94".isValidTronAddress()) + } + + @Test + fun `Base58 encode should preserve leading zero bytes as leading 1s`() { + val input = byteArrayOf(0, 0, 1, 2, 3) + + val encoded = Base58.encode(input) + assertTrue(encoded.startsWith("11")) + + val decoded = Base58.decode(encoded) + assertTrue(decoded.contentEquals(input)) + } + + @Test + fun `Base58 encode-decode should round trip for empty input`() { + assertEquals("", Base58.encode(ByteArray(0))) + assertTrue(Base58.decode("").isEmpty()) + } +} diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt index 73e241a1..5ea76180 100644 --- a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt @@ -57,6 +57,7 @@ fun chainOf( legacyPrefix = null, isTestNet = false, isEthereumBased = false, + isTronBased = false, hasCrowdloans = false, additional = "", governance = "governance", diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt index f6639ce7..014f6b02 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt @@ -94,6 +94,7 @@ import io.novafoundation.nova.core_db.migrations.AddStakingTypeToTotalRewards_44 import io.novafoundation.nova.core_db.migrations.AddSwapOption_48_49 import io.novafoundation.nova.core_db.migrations.AddTransactionVersionToRuntime_50_51 import io.novafoundation.nova.core_db.migrations.AddTransferApisTable_29_30 +import io.novafoundation.nova.core_db.migrations.AddTronSupport_73_74 import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69 import io.novafoundation.nova.core_db.migrations.AddVersioningToGovernanceDapps_32_33 import io.novafoundation.nova.core_db.migrations.AddWalletConnectSessions_39_40 @@ -166,7 +167,7 @@ import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal @Database( - version = 73, + version = 74, entities = [ AccountLocal::class, NodeLocal::class, @@ -271,6 +272,7 @@ abstract class AppDatabase : RoomDatabase() { .addMigrations(AddFavoriteDAppsOrdering_65_66, AddLegacyAddressPrefix_66_67, AddSellProviders_67_68) .addMigrations(AddTypeExtrasToMetaAccount_68_69, AddMultisigCalls_69_70, AddMultisigSupportFlag_70_71) .addMigrations(AddGifts_71_72, AddFieldsToContributions) + .addMigrations(AddTronSupport_73_74) .build() } return instance!! diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/73_74_AddTronSupport.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/73_74_AddTronSupport.kt new file mode 100644 index 00000000..ffc96381 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/73_74_AddTronSupport.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddTronSupport_73_74 = object : Migration(73, 74) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE chains ADD COLUMN isTronBased INTEGER NOT NULL DEFAULT 0") + + db.execSQL("ALTER TABLE meta_accounts ADD COLUMN tronPublicKey BLOB") + db.execSQL("ALTER TABLE meta_accounts ADD COLUMN tronAddress BLOB") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt index 79975b0e..77216596 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt @@ -21,6 +21,8 @@ data class ChainLocal( val prefix: Int, val legacyPrefix: Int?, val isEthereumBased: Boolean, + @ColumnInfo(defaultValue = "0") + val isTronBased: Boolean, val isTestNet: Boolean, @ColumnInfo(defaultValue = "1") val hasSubstrateRuntime: Boolean, diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt index e72c53d2..4886b762 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt @@ -36,7 +36,9 @@ class MetaAccountLocal( @ColumnInfo(defaultValue = "ACTIVE") val status: Status, val globallyUniqueId: String, - val typeExtras: SerializedJson? + val typeExtras: SerializedJson?, + val tronPublicKey: ByteArray? = null, + val tronAddress: ByteArray? = null, ) { enum class Status { @@ -54,6 +56,9 @@ class MetaAccountLocal( const val ETHEREUM_PUBKEY = "ethereumPublicKey" const val ETHEREUM_ADDRESS = "ethereumAddress" + const val TRON_PUBKEY = "tronPublicKey" + const val TRON_ADDRESS = "tronAddress" + const val NAME = "name" const val IS_SELECTED = "isSelected" const val POSITION = "position" @@ -83,7 +88,9 @@ class MetaAccountLocal( type = type, status = status, globallyUniqueId = globallyUniqueId, - typeExtras = typeExtras + typeExtras = typeExtras, + tronPublicKey = tronPublicKey, + tronAddress = tronAddress ).also { it.id = id } @@ -100,6 +107,8 @@ class MetaAccountLocal( if (!substrateAccountId.contentEquals(other.substrateAccountId)) return false if (!ethereumPublicKey.contentEquals(other.ethereumPublicKey)) return false if (!ethereumAddress.contentEquals(other.ethereumAddress)) return false + if (!tronPublicKey.contentEquals(other.tronPublicKey)) return false + if (!tronAddress.contentEquals(other.tronAddress)) return false if (name != other.name) return false if (parentMetaId != other.parentMetaId) return false if (isSelected != other.isSelected) return false @@ -119,6 +128,8 @@ class MetaAccountLocal( result = 31 * result + (substrateAccountId?.contentHashCode() ?: 0) result = 31 * result + (ethereumPublicKey?.contentHashCode() ?: 0) result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0) + result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0) + result = 31 * result + (tronAddress?.contentHashCode() ?: 0) result = 31 * result + name.hashCode() result = 31 * result + (parentMetaId?.hashCode() ?: 0) result = 31 * result + isSelected.hashCode() diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt index 57f1c4db..1741cb26 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt @@ -45,6 +45,13 @@ interface LightMetaAccount { val substrateAccountId: ByteArray? val ethereumAddress: ByteArray? val ethereumPublicKey: ByteArray? + + /** + * Tron account id (same keccak-based derivation as [ethereumAddress], different derivation path and address encoding). + * Only populated for [Type.SECRETS] wallets today (Phase 1 read-only support) - null for every other wallet type. + */ + val tronAddress: ByteArray? + val tronPublicKey: ByteArray? val isSelected: Boolean val name: String val type: Type @@ -81,6 +88,8 @@ fun LightMetaAccount( type: LightMetaAccount.Type, status: LightMetaAccount.Status, parentMetaId: Long?, + tronAddress: ByteArray? = null, + tronPublicKey: ByteArray? = null, ) = object : LightMetaAccount { override val id: Long = id override val globallyUniqueId: String = globallyUniqueId @@ -89,6 +98,8 @@ fun LightMetaAccount( override val substrateAccountId: ByteArray? = substrateAccountId override val ethereumAddress: ByteArray? = ethereumAddress override val ethereumPublicKey: ByteArray? = ethereumPublicKey + override val tronAddress: ByteArray? = tronAddress + override val tronPublicKey: ByteArray? = tronPublicKey override val isSelected: Boolean = isSelected override val name: String = name override val type: LightMetaAccount.Type = type @@ -155,7 +166,7 @@ sealed class MultisigAvailability { } fun MetaAccount.isUniversal(): Boolean { - return substrateAccountId != null || ethereumAddress != null + return substrateAccountId != null || ethereumAddress != null || tronAddress != null } fun MultisigAvailability.singleChainId(): ChainId? { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt index e4bd53b6..ae8d3b8b 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt @@ -63,6 +63,8 @@ class AccountMappers( substrateAccountId = substrateAccountId, ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, isSelected = isSelected, name = name, status = mapMetaAccountStateFromLocal(status), @@ -78,6 +80,8 @@ class AccountMappers( substrateAccountId = substrateAccountId, ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, isSelected = isSelected, name = name, type = type, @@ -95,6 +99,8 @@ class AccountMappers( substrateAccountId = substrateAccountId, ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, isSelected = isSelected, name = name, type = type, @@ -111,6 +117,8 @@ class AccountMappers( substrateAccountId = substrateAccountId, ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, isSelected = isSelected, name = name, type = type, @@ -128,6 +136,8 @@ class AccountMappers( substrateAccountId = substrateAccountId, ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, isSelected = isSelected, name = name, type = type, @@ -153,6 +163,8 @@ class AccountMappers( substrateAccountId = substrateAccountId, ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, isSelected = isSelected, name = name, status = mapMetaAccountStateFromLocal(status), @@ -169,6 +181,8 @@ class AccountMappers( substrateAccountId = substrateAccountId, ethereumAddress = ethereumAddress, ethereumPublicKey = ethereumPublicKey, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, chainAccounts = chainAccounts, isSelected = isSelected, name = name, diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt index 4a2e524c..891d0d17 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt @@ -3,6 +3,7 @@ package io.novafoundation.nova.feature_account_impl.data.repository.datasource import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.utils.substrateAccountId +import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId import io.novafoundation.nova.core.model.CryptoType import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey @@ -29,6 +30,7 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory { ): MetaAccountLocal { val substratePublicKey = secrets[MetaAccountSecrets.SubstrateKeypair][KeyPairSchema.PublicKey] val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey) + val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey) return MetaAccountLocal( substratePublicKey = substratePublicKey, @@ -43,7 +45,9 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory { type = MetaAccountLocal.Type.SECRETS, status = MetaAccountLocal.Status.ACTIVE, globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), - typeExtras = null + typeExtras = null, + tronPublicKey = tronPublicKey, + tronAddress = tronPublicKey?.tronPublicKeyToAccountId() ) } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt index 1f0d0b3f..5cdff614 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt @@ -28,6 +28,12 @@ import io.novasama.substrate_sdk_android.scale.Schema import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +/** + * SLIP-44 coin type 195 is Tron's registered BIP44 coin type (Ethereum's is 60). + * Not user-configurable via the "Advanced Encryption" UI yet (Phase 1 read-only Tron support) - always derived at this fixed path. + */ +const val TRON_DEFAULT_DERIVATION_PATH = "//44//195//0/0/0" + class AccountSecretsFactory( private val JsonDecoder: JsonDecoder ) { @@ -124,6 +130,7 @@ class AccountSecretsFactory( substrateDerivationPath: String?, ethereumDerivationPath: String?, accountSource: AccountSource, + tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH, ): Result = withContext(Dispatchers.Default) { val (substrateSecrets, substrateCryptoType) = chainAccountSecrets( derivationPath = substrateDerivationPath, @@ -139,13 +146,26 @@ class AccountSecretsFactory( Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedEthereumDerivationPath?.junctions.orEmpty()) } + // Tron uses the same secp256k1/BIP32 keypair generation as Ethereum, just under its own SLIP-44 coin-type (195) + // derivation path, so it always yields a different keypair from the Ethereum one above, even though the math is identical. + val tronKeypair = accountSource.castOrNull()?.let { + // "Ethereum" here just means "BIP32/ECDSA junction decoding", which is exactly what Tron's path also needs. + val decodedTronDerivationPath = decodeDerivationPath(tronDerivationPath, ethereum = true) + + val seed = deriveSeed(it.mnemonic, password = decodedTronDerivationPath?.password, ethereum = true).seed + + Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedTronDerivationPath?.junctions.orEmpty()) + } + val secrets = MetaAccountSecrets( entropy = substrateSecrets[ChainAccountSecrets.Entropy], substrateSeed = substrateSecrets[ChainAccountSecrets.Seed], substrateKeyPair = mapKeypairStructToKeypair(substrateSecrets[ChainAccountSecrets.Keypair]), substrateDerivationPath = substrateDerivationPath, ethereumKeypair = ethereumKeypair, - ethereumDerivationPath = ethereumDerivationPath + ethereumDerivationPath = ethereumDerivationPath, + tronKeypair = tronKeypair, + tronDerivationPath = tronDerivationPath ) Result(secrets = secrets, cryptoType = substrateCryptoType) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt index 170ebde8..13ba653a 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt @@ -21,7 +21,9 @@ open class DefaultMetaAccount( override val type: LightMetaAccount.Type, override val status: LightMetaAccount.Status, override val chainAccounts: Map, - override val parentMetaId: Long? + override val parentMetaId: Long?, + override val tronAddress: ByteArray? = null, + override val tronPublicKey: ByteArray? = null, ) : MetaAccount { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { @@ -31,6 +33,7 @@ open class DefaultMetaAccount( override fun hasAccountIn(chain: Chain): Boolean { return when { hasChainAccountIn(chain.id) -> true + chain.isTronBased -> tronAddress != null chain.isEthereumBased -> ethereumAddress != null else -> substrateAccountId != null } @@ -39,6 +42,7 @@ open class DefaultMetaAccount( override fun accountIdIn(chain: Chain): AccountId? { return when { hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId + chain.isTronBased -> tronAddress chain.isEthereumBased -> ethereumAddress else -> substrateAccountId } @@ -47,6 +51,7 @@ open class DefaultMetaAccount( override fun publicKeyIn(chain: Chain): ByteArray? { return when { hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey + chain.isTronBased -> tronPublicKey chain.isEthereumBased -> ethereumPublicKey else -> substratePublicKey } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt index 1ca26b4a..6e0c5164 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt @@ -21,7 +21,9 @@ class GenericLedgerMetaAccount( status: LightMetaAccount.Status, chainAccounts: Map, parentMetaId: Long?, - private val supportedGenericLedgerChains: Set + private val supportedGenericLedgerChains: Set, + tronAddress: ByteArray? = null, + tronPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -35,7 +37,9 @@ class GenericLedgerMetaAccount( type = type, status = status, chainAccounts = chainAccounts, - parentMetaId = parentMetaId + parentMetaId = parentMetaId, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey ) { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt index e07f9f14..9eae9bc5 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt @@ -21,7 +21,9 @@ class LegacyLedgerMetaAccount( type: LightMetaAccount.Type, status: LightMetaAccount.Status, chainAccounts: Map, - parentMetaId: Long? + parentMetaId: Long?, + tronAddress: ByteArray? = null, + tronPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -35,7 +37,9 @@ class LegacyLedgerMetaAccount( type = type, status = status, chainAccounts = chainAccounts, - parentMetaId = parentMetaId + parentMetaId = parentMetaId, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey ) { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt index a302a594..2f16fe46 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt @@ -27,7 +27,9 @@ class RealMultisigMetaAccount( private val otherSignatoriesUnsorted: List, override val threshold: Int, private val multisigRepository: MultisigRepository, - parentMetaId: Long? + parentMetaId: Long?, + tronAddress: ByteArray? = null, + tronPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -41,7 +43,9 @@ class RealMultisigMetaAccount( type = LightMetaAccount.Type.MULTISIG, status = status, chainAccounts = chainAccounts, - parentMetaId = parentMetaId + parentMetaId = parentMetaId, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey ), MultisigMetaAccount { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt index 1b2e4a6a..bba2effc 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt @@ -19,7 +19,9 @@ class PolkadotVaultMetaAccount( type: LightMetaAccount.Type, status: LightMetaAccount.Status, chainAccounts: Map, - parentMetaId: Long? + parentMetaId: Long?, + tronAddress: ByteArray? = null, + tronPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -33,10 +35,12 @@ class PolkadotVaultMetaAccount( type = type, status = status, chainAccounts = chainAccounts, - parentMetaId = parentMetaId + parentMetaId = parentMetaId, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey ) { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { - return !chain.isEthereumBased + return !chain.isEthereumBased && !chain.isTronBased } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt index 72b08ab9..46c2bfe7 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt @@ -21,7 +21,9 @@ internal class RealProxiedMetaAccount( status: LightMetaAccount.Status, override val proxy: ProxyAccount, chainAccounts: Map, - parentMetaId: Long? + parentMetaId: Long?, + tronAddress: ByteArray? = null, + tronPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -35,7 +37,9 @@ internal class RealProxiedMetaAccount( type = LightMetaAccount.Type.PROXIED, status = status, chainAccounts = chainAccounts, - parentMetaId = parentMetaId + parentMetaId = parentMetaId, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey ), ProxiedMetaAccount { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt index ec88239d..7b88a202 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt @@ -22,7 +22,9 @@ class RealSecretsMetaAccount( name: String, status: LightMetaAccount.Status, chainAccounts: Map, - parentMetaId: Long? + parentMetaId: Long?, + tronAddress: ByteArray? = null, + tronPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -36,7 +38,9 @@ class RealSecretsMetaAccount( type = LightMetaAccount.Type.SECRETS, status = status, chainAccounts = chainAccounts, - parentMetaId = parentMetaId + parentMetaId = parentMetaId, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey ), SecretsMetaAccount { diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/TronDerivationTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/TronDerivationTest.kt new file mode 100644 index 00000000..4d1e30f0 --- /dev/null +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/TronDerivationTest.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_account_impl.data + +import io.novafoundation.nova.common.utils.DEFAULT_DERIVATION_PATH +import io.novafoundation.nova.common.utils.ethereumAddressToAccountId +import io.novafoundation.nova.common.utils.toTronAddress +import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId +import io.novafoundation.nova.feature_account_impl.data.secrets.TRON_DEFAULT_DERIVATION_PATH +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.Bip32EcdsaKeypairFactory +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.generate +import io.novasama.substrate_sdk_android.encrypt.seed.bip39.Bip39SeedFactory +import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey +import io.novasama.substrate_sdk_android.extensions.toAccountId +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Cross-checks Tron address derivation (Phase 1, read-only support) against values independently computed + * outside this codebase (Python: hashlib/ecdsa/pycryptodome, none of which are used by the app itself) for the + * standard BIP39 test mnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon + * abandon about". + * + * The Ethereum test case is included first as a control: it reproduces the exact same well-known reference + * address for this mnemonic that ethers.js/MetaMask documentation uses + * (0x9858EfFD232B4033E47d90003D41EC34EcaEda94 at m/44'/60'/0'/0/0), which validates that this project's BIP32 + + * secp256k1 + keccak pipeline (`Bip32EcdsaKeypairFactory` + `asEthereumPublicKey().toAccountId()`, from + * `substrate_sdk_android`) behaves as expected. The Tron test case then reuses the exact same pipeline under + * Tron's own SLIP-44 coin-type-195 derivation path, which necessarily yields a different keypair (and therefore + * a different address) even though the math is identical - this is expected, not a bug. + */ +class TronDerivationTest { + + private val testMnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + @Test + fun `should derive well-known reference Ethereum address for the standard test mnemonic`() { + val expectedAccountId = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94".ethereumAddressToAccountId() + + val seed = Bip39SeedFactory.deriveSeed(testMnemonic, password = null) + val keypair = Bip32EcdsaKeypairFactory.generate(seed.seed, BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH) + + val actualAccountId = keypair.publicKey.asEthereumPublicKey().toAccountId().value + + assertArrayEquals(expectedAccountId, actualAccountId) + } + + @Test + fun `should derive Tron address for the standard test mnemonic at the coin-195 path`() { + // Independently verified via a from-scratch Python implementation (hashlib SHA-256 for Base58Check, + // pycryptodome Keccak-256, `ecdsa` for secp256k1/BIP32) - see task verification notes. + val expectedTronAddress = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH" + + val seed = Bip39SeedFactory.deriveSeed(testMnemonic, password = null) + val keypair = Bip32EcdsaKeypairFactory.generate(seed.seed, TRON_DEFAULT_DERIVATION_PATH) + + val accountId = keypair.publicKey.tronPublicKeyToAccountId() + val actualTronAddress = accountId.toTronAddress() + + assertEquals(expectedTronAddress, actualTronAddress) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt index ef918962..2b99a9c1 100644 --- a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt @@ -124,6 +124,7 @@ class CustomChainFactory( legacyAddressPrefix = null, types = prefilledChain?.types, isEthereumBased = isEthereumBased, + isTronBased = false, isTestNet = prefilledChain?.isTestNet.orFalse(), source = Chain.Source.CUSTOM, hasSubstrateRuntime = hasSubstrateRuntime, diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt index 6b373149..9b505934 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt @@ -29,6 +29,8 @@ class TypeBasedAssetSourceRegistry( private val evmErc20Source: Lazy, private val evmNativeSource: Lazy, private val equilibriumAssetSource: Lazy, + private val tronNativeSource: Lazy, + private val trc20Source: Lazy, private val unsupportedBalanceSource: AssetSource, private val nativeAssetEventDetector: NativeAssetEventDetector, @@ -45,6 +47,8 @@ class TypeBasedAssetSourceRegistry( is Chain.Asset.Type.EvmErc20 -> evmErc20Source.get() is Chain.Asset.Type.EvmNative -> evmNativeSource.get() is Chain.Asset.Type.Equilibrium -> equilibriumAssetSource.get() + is Chain.Asset.Type.TronNative -> tronNativeSource.get() + is Chain.Asset.Type.Trc20 -> trc20Source.get() Chain.Asset.Type.Unsupported -> unsupportedBalanceSource } } @@ -57,6 +61,8 @@ class TypeBasedAssetSourceRegistry( add(evmNativeSource.get()) add(evmErc20Source.get()) add(equilibriumAssetSource.get()) + add(tronNativeSource.get()) + add(trc20Source.get()) } } @@ -64,6 +70,8 @@ class TypeBasedAssetSourceRegistry( return when (chainAsset.type) { is Chain.Asset.Type.Equilibrium, Chain.Asset.Type.EvmNative, + is Chain.Asset.Type.TronNative, + is Chain.Asset.Type.Trc20, Chain.Asset.Type.Unsupported -> UnsupportedEventDetector() diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt new file mode 100644 index 00000000..d3120b3b --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.trc20 + +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.pollingBalanceFlow +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl +import io.novafoundation.nova.runtime.ext.requireTrc20 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +/** + * TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via TronGrid's REST API (the same + * `/v1/accounts/{address}` endpoint used for native TRX - TronGrid returns both in one response) and polls for + * updates. No transfer/history support here - see `TronAssetsModule`. + */ +class Trc20AssetBalance( + private val assetCache: AssetCache, + private val tronGridApi: TronGridApi, +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + // TRC-20 tokens do not support locks + return emptyFlow() + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + // TRC-20 tokens do not have an existential deposit concept + return BigInteger.ZERO + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + val contractAddress = chainAsset.requireTrc20().contractAddress + val address = chain.addressOf(accountId) + + val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), address, contractAddress) + + return ChainAssetBalance.fromFree(chainAsset, balance) + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + // Not on the critical sync path (mirrors EvmNativeAssetBalance) - out of scope for Phase 1 read-only support. + TODO("Not yet implemented") + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + val contractAddress = chainAsset.requireTrc20().contractAddress + val baseUrl = chain.requireTronGridBaseUrl() + val address = chain.addressOf(accountId) + + return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, address, contractAddress) } + .map { balance -> + assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance) + + BalanceSyncUpdate.NoCause + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt new file mode 100644 index 00000000..e1b61ddd --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +private const val TRON_BALANCE_POLLING_INTERVAL_MS = 30_000L + +/** + * TronGrid is a plain REST API with no push/subscription mechanism (unlike Ethereum nodes, which expose a + * `newHeads`-style websocket subscription EVM balance sync piggybacks on). So balance updates for Tron-based + * assets are polled instead of pushed: fetch immediately, then re-fetch on an interval, only emitting when the + * balance actually changed. + */ +internal fun pollingBalanceFlow( + intervalMs: Long = TRON_BALANCE_POLLING_INTERVAL_MS, + fetch: suspend () -> Balance +): Flow = flow { + var lastEmitted: Balance? = null + + while (true) { + val latest = fetch() + + if (latest != lastEmitted) { + lastEmitted = latest + emit(latest) + } + + delay(intervalMs) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronNativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronNativeAssetBalance.kt new file mode 100644 index 00000000..d65c3a6f --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronNativeAssetBalance.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative + +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +/** + * Native TRX balance on a Tron-based chain. Read-only (Phase 1): fetches via TronGrid's REST API and polls for + * updates, since TronGrid has no push/subscription mechanism. No transfer/history support here - see + * `TronAssetsModule` for how this is paired with `UnsupportedAssetTransfers`/`UnsupportedAssetHistory`. + */ +class TronNativeAssetBalance( + private val assetCache: AssetCache, + private val tronGridApi: TronGridApi, +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + // Tron native balance does not support locks + return emptyFlow() + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + // Tron does not have an existential deposit concept + return BigInteger.ZERO + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + val balance = tronGridApi.fetchNativeBalance(chain.requireTronGridBaseUrl(), chain.addressOf(accountId)) + + return ChainAssetBalance.fromFree(chainAsset, balance) + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + // Not on the critical sync path (mirrors EvmNativeAssetBalance, which also leaves this unimplemented) - + // out of scope for Phase 1 read-only support. + TODO("Not yet implemented") + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + val baseUrl = chain.requireTronGridBaseUrl() + val address = chain.addressOf(accountId) + + return pollingBalanceFlow { tronGridApi.fetchNativeBalance(baseUrl, address) } + .map { balance -> + assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance) + + BalanceSyncUpdate.NoCause + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt index d41e6189..5fc78510 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt @@ -120,6 +120,7 @@ private fun Chain.Asset.existentialDepositError(amount: BigDecimal): WillRemoveA is Type.Orml -> WillRemoveAccount.WillBurnDust is Type.Statemine -> WillRemoveAccount.WillTransferDust(amount) is Type.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust + is Type.Trc20, Type.TronNative -> WillRemoveAccount.WillBurnDust is Type.Equilibrium -> WillRemoveAccount.WillBurnDust Type.Unsupported -> throw IllegalArgumentException("Unsupported") } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt new file mode 100644 index 00000000..887cd961 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron + +import io.novafoundation.nova.common.data.network.UserAgent +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResponse +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Url + +interface RetrofitTronGridApi { + + @GET + @Headers(UserAgent.NOVA) + suspend fun getAccount(@Url url: String): TronAccountResponse +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt new file mode 100644 index 00000000..22853ca7 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger + +interface TronGridApi { + + suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance + + suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance +} + +class RealTronGridApi( + private val retrofitApi: RetrofitTronGridApi +) : TronGridApi { + + override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance { + val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO + + return accountData.balance?.toBigInteger() ?: BigInteger.ZERO + } + + override suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance { + val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO + + val rawBalance = accountData.trc20.orEmpty() + .firstNotNullOfOrNull { entry -> entry[contractAddress] } + + return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO + } + + private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount( + url = accountUrl(baseUrl, address) + ).data?.firstOrNull() + + private fun accountUrl(baseUrl: String, address: String): String { + return "${baseUrl.trimEnd('/')}/v1/accounts/$address" + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronAccountResponse.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronAccountResponse.kt new file mode 100644 index 00000000..5aee9707 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronAccountResponse.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.model + +/** + * Response shape of TronGrid's `GET /v1/accounts/{address}`. + * + * An account that has never received any TRX/TRC20 transfer is not yet "activated" on-chain and TronGrid + * returns an empty `data` array for it (not an error) - callers should treat that as a zero balance. + */ +class TronAccountResponse( + val data: List? = null, + val success: Boolean = true +) + +class TronAccountData( + /** + * Native TRX balance, denominated in SUN (1 TRX = 1_000_000 SUN), matching this chain's configured `precision: 6`. + * Absent for freshly-activated accounts that hold TRX but have never been observed with a balance field by the indexer. + */ + val balance: Long? = null, + + /** + * List of single-entry maps: TRC20 contract address (Base58Check, same format as our chain config's `contractAddress`) -> balance string. + * Only contains entries for tokens the account has ever interacted with; a token missing from this list means a zero balance. + */ + val trc20: List>? = null +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt index aa0513dc..ebe0597d 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt @@ -21,6 +21,7 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets EvmErc20AssetsModule::class, EvmNativeAssetsModule::class, EquilibriumAssetsModule::class, + TronAssetsModule::class, UnsupportedAssetsModule::class ] ) @@ -35,6 +36,8 @@ class AssetsModule { @EvmErc20Assets evmErc20: Lazy, @EvmNativeAssets evmNative: Lazy, @EquilibriumAsset equilibrium: Lazy, + @TronNativeAssets tronNative: Lazy, + @Trc20Assets trc20: Lazy, @UnsupportedAssets unsupported: AssetSource, nativeAssetEventDetector: NativeAssetEventDetector, @@ -48,6 +51,8 @@ class AssetsModule { evmErc20Source = evmErc20, evmNativeSource = evmNative, equilibriumAssetSource = equilibrium, + tronNativeSource = tronNative, + trc20Source = trc20, unsupportedBalanceSource = unsupported, nativeAssetEventDetector = nativeAssetEventDetector, diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt new file mode 100644 index 00000000..90f633e5 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.trc20.Trc20AssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.TronNativeAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.UnsupportedAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import javax.inject.Qualifier + +@Qualifier +annotation class TronNativeAssets + +@Qualifier +annotation class Trc20Assets + +/** + * Tron/TRC-20 support - Phase 1, read-only. + * + * Only `balance` is implemented for real; `transfers`/`history` reuse the same `Unsupported*` stubs the rest of + * the app uses for asset types with no send/history support yet (see `UnsupportedAssetsModule`). This is + * intentional: no transaction-building/signing code exists for Tron yet - that is future, separate work. + */ +@Module +class TronAssetsModule { + + @Provides + @FeatureScope + fun provideRetrofitTronGridApi( + networkApiCreator: NetworkApiCreator + ): RetrofitTronGridApi = networkApiCreator.create(RetrofitTronGridApi::class.java) + + @Provides + @FeatureScope + fun provideTronGridApi(retrofitTronGridApi: RetrofitTronGridApi): TronGridApi = RealTronGridApi(retrofitTronGridApi) + + @Provides + @FeatureScope + fun provideTronNativeBalance(assetCache: AssetCache, tronGridApi: TronGridApi) = TronNativeAssetBalance(assetCache, tronGridApi) + + @Provides + @FeatureScope + fun provideTrc20Balance(assetCache: AssetCache, tronGridApi: TronGridApi) = Trc20AssetBalance(assetCache, tronGridApi) + + @Provides + @TronNativeAssets + @FeatureScope + fun provideTronNativeAssetSource( + tronNativeAssetBalance: TronNativeAssetBalance, + unsupportedAssetTransfers: UnsupportedAssetTransfers, + unsupportedAssetHistory: UnsupportedAssetHistory, + ): AssetSource = StaticAssetSource( + transfers = unsupportedAssetTransfers, + balance = tronNativeAssetBalance, + history = unsupportedAssetHistory + ) + + @Provides + @Trc20Assets + @FeatureScope + fun provideTrc20AssetSource( + trc20AssetBalance: Trc20AssetBalance, + unsupportedAssetTransfers: UnsupportedAssetTransfers, + unsupportedAssetHistory: UnsupportedAssetHistory, + ): AssetSource = StaticAssetSource( + transfers = unsupportedAssetTransfers, + balance = trc20AssetBalance, + history = unsupportedAssetHistory + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt index c9837e56..2f7afc1f 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt @@ -14,7 +14,11 @@ import io.novafoundation.nova.common.utils.emptySubstrateAccountId import io.novafoundation.nova.common.utils.findIsInstanceOrNull import io.novafoundation.nova.common.utils.formatNamed import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.common.utils.emptyTronAccountId import io.novafoundation.nova.common.utils.substrateAccountId +import io.novafoundation.nova.common.utils.toTronAddress +import io.novafoundation.nova.common.utils.tronAddressToAccountId +import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId import io.novafoundation.nova.core_db.model.AssetAndChainId import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.ALEPH_ZERO @@ -266,10 +270,10 @@ fun Chain.supportsLegacyAddressFormat() = legacyAddressPrefix != null fun Chain.requireGenesisHash() = requireNotNull(genesisHash) fun Chain.addressOf(accountId: ByteArray): String { - return if (isEthereumBased) { - accountId.toEthereumAddress() - } else { - accountId.toAddress(addressPrefix.toShort()) + return when { + isTronBased -> accountId.toTronAddress() + isEthereumBased -> accountId.toEthereumAddress() + else -> accountId.toAddress(addressPrefix.toShort()) } } @@ -278,7 +282,7 @@ fun Chain.addressOf(accountId: AccountIdKey): String { } fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? { - return if (isEthereumBased) { + return if (isEthereumBased || isTronBased) { null } else { legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) } @@ -290,10 +294,10 @@ fun ByteArray.toEthereumAddress(): String { } fun Chain.accountIdOf(address: String): ByteArray { - return if (isEthereumBased) { - address.asEthereumAddress().toAccountId().value - } else { - address.toAccountId() + return when { + isTronBased -> address.tronAddressToAccountId() + isEthereumBased -> address.asEthereumAddress().toAccountId().value + else -> address.toAccountId() } } @@ -323,10 +327,10 @@ fun Chain.accountIdOrNull(address: String): ByteArray? { return runCatching { accountIdOf(address) }.getOrNull() } -fun Chain.emptyAccountId() = if (isEthereumBased) { - emptyEthereumAccountId() -} else { - emptySubstrateAccountId() +fun Chain.emptyAccountId() = when { + isTronBased -> emptyTronAccountId() + isEthereumBased -> emptyEthereumAccountId() + else -> emptySubstrateAccountId() } fun Chain.emptyAccountIdKey() = emptyAccountId().intoKey() @@ -336,10 +340,10 @@ fun Chain.accountIdOrDefault(maybeAddress: String): ByteArray { } fun Chain.accountIdOf(publicKey: ByteArray): ByteArray { - return if (isEthereumBased) { - publicKey.asEthereumPublicKey().toAccountId().value - } else { - publicKey.substrateAccountId() + return when { + isTronBased -> publicKey.tronPublicKeyToAccountId() + isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value + else -> publicKey.substrateAccountId() } } @@ -525,6 +529,26 @@ fun Chain.Asset.requireErc20(): Type.EvmErc20 { return type } +fun Chain.Asset.requireTrc20(): Type.Trc20 { + require(type is Type.Trc20) + + return type +} + +/** + * The TronGrid-style REST API base url for a Tron-based chain. + * Unlike EVM chains (where balance/rpc calls go through the chain's `nodes` JSON-RPC/WS endpoints and only tx-history + * REST APIs are sourced from a separate `externalApis` entry), Tron has no JSON-RPC/WS node concept at all - the + * configured `nodes` entry directly *is* the TronGrid REST API base url used for both balance and history. + */ +fun Chain.requireTronGridBaseUrl(): String { + require(isTronBased) { "Chain $id is not Tron-based" } + + return requireNotNull(nodes.nodes.minByOrNull { it.orderId }?.unformattedUrl) { + "No TronGrid node configured for chain $id" + } +} + fun Chain.Asset.requireEquilibrium(): Type.Equilibrium { require(type is Type.Equilibrium) @@ -588,6 +612,8 @@ val Chain.Asset.onChainAssetId: String? is Type.EvmErc20 -> this.type.contractAddress is Type.Native -> null is Type.EvmNative -> null + is Type.Trc20 -> this.type.contractAddress + Type.TronNative -> null Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}") } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt index ef131be6..f5925053 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt @@ -9,6 +9,9 @@ const val ASSET_UNSUPPORTED = "unsupported" const val ASSET_EVM_ERC20 = "evm" const val ASSET_EVM_NATIVE = "evmNative" +const val ASSET_TRON_NATIVE = "tronNative" +const val ASSET_TRC20 = "trc20" + const val ASSET_EQUILIBRIUM = "equilibrium" const val ASSET_EQUILIBRIUM_ON_CHAIN_ID = "assetId" diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt index 5bd511e5..0a61c94e 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt @@ -59,6 +59,12 @@ fun mapChainAssetTypeToRaw(type: Chain.Asset.Type): Pair ASSET_EVM_NATIVE to null + is Chain.Asset.Type.TronNative -> ASSET_TRON_NATIVE to null + + is Chain.Asset.Type.Trc20 -> ASSET_TRC20 to mapOf( + EVM_EXTRAS_CONTRACT_ADDRESS to type.contractAddress + ) + is Chain.Asset.Type.Equilibrium -> ASSET_EQUILIBRIUM to mapOf( ASSET_EQUILIBRIUM_ON_CHAIN_ID to type.id.toString() ) @@ -121,6 +127,7 @@ fun mapChainToLocal(chain: Chain, gson: Gson): ChainLocal { prefix = chain.addressPrefix, legacyPrefix = chain.legacyAddressPrefix, isEthereumBased = chain.isEthereumBased, + isTronBased = chain.isTronBased, isTestNet = chain.isTestNet, hasSubstrateRuntime = chain.hasSubstrateRuntime, pushSupport = chain.pushSupport, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt index 14f13336..8b85a865 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt @@ -85,6 +85,14 @@ private fun mapChainAssetTypeFromRaw(type: String?, typeExtras: Map Chain.Asset.Type.EvmNative + ASSET_TRON_NATIVE -> Chain.Asset.Type.TronNative + + ASSET_TRC20 -> { + Chain.Asset.Type.Trc20( + contractAddress = typeExtras!![EVM_EXTRAS_CONTRACT_ADDRESS] as String + ) + } + ASSET_EQUILIBRIUM -> Chain.Asset.Type.Equilibrium((typeExtras!![ASSET_EQUILIBRIUM_ON_CHAIN_ID] as String).toBigInteger()) else -> Chain.Asset.Type.Unsupported @@ -267,6 +275,7 @@ fun mapChainLocalToChain( addressPrefix = prefix, legacyAddressPrefix = legacyPrefix, isEthereumBased = isEthereumBased, + isTronBased = isTronBased, isTestNet = isTestNet, hasCrowdloans = hasCrowdloans, pushSupport = pushSupport, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt index ac1d8093..8f1b36b1 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt @@ -20,6 +20,7 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainAsset import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote private const val ETHEREUM_OPTION = "ethereumBased" +private const val TRON_OPTION = "tronBased" private const val CROWDLOAN_OPTION = "crowdloans" private const val TESTNET_OPTION = "testnet" private const val PROXY_OPTION = "proxy" @@ -90,6 +91,7 @@ fun mapRemoteChainToLocal( prefix = addressPrefix, legacyPrefix = legacyAddressPrefix, isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty, + isTronBased = TRON_OPTION in optionsOrEmpty, isTestNet = TESTNET_OPTION in optionsOrEmpty, hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty, supportProxy = PROXY_OPTION in optionsOrEmpty, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt index 2a961b00..03801dec 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt @@ -32,6 +32,7 @@ data class Chain( val legacyAddressPrefix: Int?, val types: Types?, val isEthereumBased: Boolean, + val isTronBased: Boolean, val isTestNet: Boolean, val source: Source, val hasSubstrateRuntime: Boolean, @@ -124,6 +125,19 @@ data class Chain( val id: BigInteger ) : Type() + /** + * Native TRX balance on a Tron-based chain. + * Balance is fetched from a TronGrid-style REST API rather than JSON-RPC. + */ + object TronNative : Type() + + /** + * TRC-20 token on a Tron-based chain (EVM-compatible token standard, different REST API shape from Ethereum's ERC-20). + */ + data class Trc20( + val contractAddress: String + ) : Type() + object Unsupported : Type() }