Add read-only Tron (TRC20) chain family support (#9)

* Add read-only Tron (TRC20) chain family support

Adds Tron as a third chain family alongside Substrate and EVM:
address derivation (BIP44 coin type 195, secp256k1 reused from the
existing Ethereum path, Base58Check encoding), TronGrid-backed TRX
and TRC20 USDT balance display, and a Room migration for the new
per-account Tron key/address columns. Read-only only — no send,
signing, or broadcast support yet.

* fix: exhaustive when branches for Trc20/TronNative asset types

ChainExt.kt's onChainAssetId and Common.kt's existentialDepositError
both switch exhaustively over Chain.Asset.Type and didn't have cases
for the new Trc20/TronNative variants, breaking compilation. Trc20
mirrors EvmErc20 (contract address as the on-chain id, dust burns
rather than transfers on removal); TronNative mirrors EvmNative.
This commit is contained in:
2026-07-07 06:03:20 -07:00
committed by GitHub
parent 85bde7e448
commit 46d2a91510
39 changed files with 864 additions and 41 deletions
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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\""
@@ -25,6 +25,9 @@ object MetaAccountSecrets : Schema<MetaAccountSecrets>() {
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<ChainAccountSecrets>() {
@@ -42,6 +45,8 @@ fun MetaAccountSecrets(
substrateDerivationPath: String? = null,
ethereumKeypair: Keypair? = null,
ethereumDerivationPath: String? = null,
tronKeypair: Keypair? = null,
tronDerivationPath: String? = null,
): EncodableStruct<MetaAccountSecrets> = 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<MetaAccountSecrets>.substrateDerivationPath
val EncodableStruct<MetaAccountSecrets>.ethereumDerivationPath
get() = get(MetaAccountSecrets.EthereumDerivationPath)
val EncodableStruct<MetaAccountSecrets>.tronDerivationPath
get() = get(MetaAccountSecrets.TronDerivationPath)
val EncodableStruct<MetaAccountSecrets>.entropy
get() = get(MetaAccountSecrets.Entropy)
@@ -98,6 +115,9 @@ val EncodableStruct<MetaAccountSecrets>.substrateKeypair
val EncodableStruct<MetaAccountSecrets>.ethereumKeypair
get() = get(MetaAccountSecrets.EthereumKeypair)
val EncodableStruct<MetaAccountSecrets>.tronKeypair
get() = get(MetaAccountSecrets.TronKeypair)
val EncodableStruct<ChainAccountSecrets>.derivationPath
get() = get(ChainAccountSecrets.DerivationPath)
@@ -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 }
@@ -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())
}
}
@@ -57,6 +57,7 @@ fun chainOf(
legacyPrefix = null,
isTestNet = false,
isEthereumBased = false,
isTronBased = false,
hasCrowdloans = false,
additional = "",
governance = "governance",
@@ -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!!
@@ -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")
}
}
@@ -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,
@@ -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()
@@ -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? {
@@ -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,
@@ -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()
)
}
}
@@ -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<MetaAccountSecrets> = 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<AccountSource.Mnemonic>()?.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)
@@ -21,7 +21,9 @@ open class DefaultMetaAccount(
override val type: LightMetaAccount.Type,
override val status: LightMetaAccount.Status,
override val chainAccounts: Map<ChainId, MetaAccount.ChainAccount>,
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
}
@@ -21,7 +21,9 @@ class GenericLedgerMetaAccount(
status: LightMetaAccount.Status,
chainAccounts: Map<ChainId, MetaAccount.ChainAccount>,
parentMetaId: Long?,
private val supportedGenericLedgerChains: Set<ChainId>
private val supportedGenericLedgerChains: Set<ChainId>,
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 {
@@ -21,7 +21,9 @@ class LegacyLedgerMetaAccount(
type: LightMetaAccount.Type,
status: LightMetaAccount.Status,
chainAccounts: Map<ChainId, MetaAccount.ChainAccount>,
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 {
@@ -27,7 +27,9 @@ class RealMultisigMetaAccount(
private val otherSignatoriesUnsorted: List<AccountIdKey>,
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 {
@@ -19,7 +19,9 @@ class PolkadotVaultMetaAccount(
type: LightMetaAccount.Type,
status: LightMetaAccount.Status,
chainAccounts: Map<ChainId, MetaAccount.ChainAccount>,
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
}
}
@@ -21,7 +21,9 @@ internal class RealProxiedMetaAccount(
status: LightMetaAccount.Status,
override val proxy: ProxyAccount,
chainAccounts: Map<ChainId, MetaAccount.ChainAccount>,
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 {
@@ -22,7 +22,9 @@ class RealSecretsMetaAccount(
name: String,
status: LightMetaAccount.Status,
chainAccounts: Map<ChainId, MetaAccount.ChainAccount>,
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 {
@@ -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)
}
}
@@ -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,
@@ -29,6 +29,8 @@ class TypeBasedAssetSourceRegistry(
private val evmErc20Source: Lazy<AssetSource>,
private val evmNativeSource: Lazy<AssetSource>,
private val equilibriumAssetSource: Lazy<AssetSource>,
private val tronNativeSource: Lazy<AssetSource>,
private val trc20Source: Lazy<AssetSource>,
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()
@@ -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<Nothing>()
}
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<TransferableBalanceUpdatePoint> {
// 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<BalanceSyncUpdate> {
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
}
}
}
@@ -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<Balance> = flow {
var lastEmitted: Balance? = null
while (true) {
val latest = fetch()
if (latest != lastEmitted) {
lastEmitted = latest
emit(latest)
}
delay(intervalMs)
}
}
@@ -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<Nothing>()
}
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<TransferableBalanceUpdatePoint> {
// 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<BalanceSyncUpdate> {
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
}
}
}
@@ -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")
}
@@ -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
}
@@ -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"
}
}
@@ -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<TronAccountData>? = 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<Map<String, String>>? = null
)
@@ -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<AssetSource>,
@EvmNativeAssets evmNative: Lazy<AssetSource>,
@EquilibriumAsset equilibrium: Lazy<AssetSource>,
@TronNativeAssets tronNative: Lazy<AssetSource>,
@Trc20Assets trc20: Lazy<AssetSource>,
@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,
@@ -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
)
}
@@ -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}")
}
@@ -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"
@@ -59,6 +59,12 @@ fun mapChainAssetTypeToRaw(type: Chain.Asset.Type): Pair<String, Map<String, Any
is Chain.Asset.Type.EvmNative -> 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,
@@ -85,6 +85,14 @@ private fun mapChainAssetTypeFromRaw(type: String?, typeExtras: Map<String, Any?
ASSET_EVM_NATIVE -> 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,
@@ -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,
@@ -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()
}