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
@@ -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 {