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
@@ -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()
}