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