merge: reconcile with feature/trc20-tron-send into one combined branch

Bitcoin was branched off main independently instead of continuing on top
of the Tron send branch, which caused real divergence: both branches touch
the same shared signing/DI code (multiChainEncryptionFor, getMetaAccountKeypair,
NodeHealthStateTesterFactory, AssetsModule/TypeBasedAssetSourceRegistry,
CloudBackup schema, Fee model), and both need to ship together in one
coordinated release once wallet-util's master is unblocked. Merging now
reconciles that before it gets any harder, and gives one branch/versionCode
history for device testing instead of two that silently diverge.

Conflict resolutions of note:
- SecretStoreV2/SecretsSigner: getKeypair and multiChainEncryptionFor now
  recognize Tron AND Bitcoin accounts (isTronBased/isBitcoinBased both
  threaded through, checked before the Ethereum fallback).
- AccountDataSourceImpl: both address-backfill migrations now run
  sequentially in one coroutine (Tron's fix for a race against the legacy
  migration applies equally to Bitcoin's backfill).
- CloudBackup: kept Tron's forward-looking Solana schema reservation
  alongside Tron's and Bitcoin's now-real fields.
- ChainRegistryModule/NodeHealthStateTesterFactory: adopted Tron's
  self-contained short-lived OkHttpClient (simpler than threading the
  shared app-wide client through RuntimeDependencies, which is reverted).
- RealSecretsMetaAccount: extended Tron's chain-account MultiChainEncryption
  fix to also cover Bitcoin, for the same underlying reason.
- ChainExt.isValidAddress: Tron's branch independently closed the
  pre-existing Tron validation gap; kept alongside Bitcoin's own check.
This commit is contained in:
2026-07-12 19:02:08 -07:00
62 changed files with 2851 additions and 265 deletions
@@ -156,24 +156,28 @@ val AccountSecrets.isChainAccountSecrets
suspend fun SecretStoreV2.getMetaAccountKeypair(
metaId: Long,
isEthereum: Boolean,
isTron: Boolean = false,
isBitcoin: Boolean = false,
): Keypair = withContext(Dispatchers.Default) {
val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId)
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isBitcoin)
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin)
}
fun mapMetaAccountSecretsToKeypair(
secrets: EncodableStruct<MetaAccountSecrets>,
ethereum: Boolean,
tron: Boolean = false,
bitcoin: Boolean = false,
): Keypair {
val keypairStruct = if (bitcoin) {
secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret()
} else if (ethereum) {
secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
} else {
secrets[MetaAccountSecrets.SubstrateKeypair]
// Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different
// SLIP-44 path - each must be checked before `ethereum`, not folded into it, or that account would get
// signed with the wrong (Ethereum) private key.
val keypairStruct = when {
tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret()
bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret()
ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
else -> secrets[MetaAccountSecrets.SubstrateKeypair]
}
return mapKeypairStructToKeypair(keypairStruct)
@@ -207,6 +211,8 @@ private fun noEthereumSecret(): Nothing = error("No ethereum keypair found")
private fun noBitcoinSecret(): Nothing = error("No bitcoin keypair found")
private fun noTronSecret(): Nothing = error("No tron keypair found")
fun mapKeypairStructToKeypair(struct: EncodableStruct<KeyPairSchema>): Keypair {
return Keypair(
publicKey = struct[KeyPairSchema.PublicKey],
@@ -2,6 +2,7 @@ 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.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.AccountId
import java.math.BigInteger
@@ -113,3 +114,21 @@ fun String.tronAddressToAccountId(): AccountId {
fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess
fun emptyTronAccountId() = ByteArray(20) { 1 }
/**
* Hex form of a Tron address (`0x41` prefix byte ++ accountId, hex-encoded, no `0x` prefix), e.g.
* `41a614f803b6fd780986a42c78ec9c7f77e6ded13c`. This is the format TronGrid's `/wallet/` transaction
* construction/broadcast endpoints expect when called with `"visible": false` (as opposed to the human-facing
* Base58Check form used by the `/v1/accounts/{address}` balance endpoint and by [toTronAddress]).
*/
fun AccountId.toTronHexAddress(): String {
require(size == 20) { "Tron account id must be 20 bytes, got $size" }
return byteArrayOf(TRON_ADDRESS_PREFIX_BYTE).toHexString(withPrefix = false) + toHexString(withPrefix = false)
}
/**
* Converts a human-facing Base58Check Tron address (e.g. a TRC20 `contractAddress` from chain config) directly
* into the hex form described in [toTronHexAddress].
*/
fun String.tronAddressToHexAddress(): String = tronAddressToAccountId().toTronHexAddress()