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