mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 19:35:51 +00:00
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:
+14
@@ -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,
|
||||
|
||||
+5
-1
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -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)
|
||||
|
||||
+6
-1
@@ -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
|
||||
}
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
|
||||
+7
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
|
||||
+63
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user