fix: cloud backup schema dropped Tron address/keypair on every round trip

WalletPublicInfo/WalletPrivateInfo had no tron field at all, so any wallet
created via the (default) cloud-backup wallet creation flow, or restored
from a cloud backup, silently lost its Tron address and keypair even though
AccountSecretsFactory/SecretsMetaAccountLocalFactory derived them correctly
moments earlier - this is why TRX/USDT-TRC20 never appeared for any wallet,
new or old, confirmed via a device-side diagnostic dump of the actual DB row
(tronAddress=NULL) after a fresh wallet creation.

Also reserves (but does not wire up) solana/bitcoin fields in the same
schema, since native derivation for those doesn't exist yet - adding them
now means that work won't need another silent-drop-prone pass through this
same schema later.

Adds a regression test exercising the exact apply-diff path that lost the
data, plus tron support in the shared cloud-backup test builder DSL.
This commit is contained in:
2026-07-11 04:09:34 -07:00
parent c713ebf6b2
commit c78b325e6d
4 changed files with 184 additions and 7 deletions
@@ -17,6 +17,8 @@ import io.novafoundation.nova.common.data.secrets.v2.publicKey
import io.novafoundation.nova.common.data.secrets.v2.seed
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.common.utils.filterNotNull
import io.novafoundation.nova.common.utils.findById
import io.novafoundation.nova.common.utils.mapToSet
@@ -90,6 +92,7 @@ class RealLocalAccountsCloudBackupFacade(
substrate = baseSecrets.getSubstrateBackupSecrets(),
ethereum = baseSecrets.getEthereumBackupSecrets(),
chainAccounts = emptyList(),
tron = baseSecrets.getTronBackupSecrets(),
)
return CloudBackup(
@@ -357,6 +360,7 @@ class RealLocalAccountsCloudBackupFacade(
substrate = prepareSubstrateBackupSecrets(baseSecrets, joinedMetaAccountInfo),
ethereum = baseSecrets.getEthereumBackupSecrets(),
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
tron = baseSecrets.getTronBackupSecrets(),
)
}
@@ -466,7 +470,9 @@ class RealLocalAccountsCloudBackupFacade(
substrateKeyPair = substrate?.keypair?.toLocalKeyPair() ?: return null,
substrateDerivationPath = substrate?.derivationPath,
ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(),
ethereumDerivationPath = ethereum?.derivationPath
ethereumDerivationPath = ethereum?.derivationPath,
tronKeypair = tron?.keypair?.toLocalKeyPair(),
tronDerivationPath = tron?.derivationPath
)
}
@@ -479,6 +485,15 @@ class RealLocalAccountsCloudBackupFacade(
)
}
private fun EncodableStruct<MetaAccountSecrets>?.getTronBackupSecrets(): CloudBackup.WalletPrivateInfo.TronSecrets? {
if (this == null) return null
return CloudBackup.WalletPrivateInfo.TronSecrets(
keypair = tronKeypair?.toBackupKeypairSecrets() ?: return null,
derivationPath = tronDerivationPath
)
}
private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? {
if (this == null) return null
@@ -520,7 +535,9 @@ class RealLocalAccountsCloudBackupFacade(
ethereumPublicKey = metaAccount.ethereumPublicKey,
name = metaAccount.name,
type = metaAccount.type.toBackupWalletType() ?: return null,
chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) }
chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) },
tronAddress = metaAccount.tronAddress,
tronPublicKey = metaAccount.tronPublicKey,
)
}
@@ -542,7 +559,9 @@ class RealLocalAccountsCloudBackupFacade(
isSelected = isSelected,
position = accountPosition,
status = MetaAccountLocal.Status.ACTIVE,
typeExtras = null
typeExtras = null,
tronAddress = tronAddress,
tronPublicKey = tronPublicKey,
).also {
if (localIdOverwrite != null) {
it.id = localIdOverwrite
@@ -7,6 +7,8 @@ import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.entropy
import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.core.model.CryptoType
import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal
@@ -536,6 +538,64 @@ class RealLocalAccountsCloudBackupFacadeTest {
verifyEvent(expectedEvent)
}
// Regression test for a wallet's Tron address/keypair being silently dropped on a cloud backup round trip:
// WalletPublicInfo/WalletPrivateInfo had no tron field at all until this fix, so a backup written from a
// wallet that genuinely had a Tron address, once applied back to local (e.g. after "clear all data" +
// restore, or on a fresh install created via the cloud-backup-first-wallet flow), landed with
// tronAddress/tronPublicKey/tronKeypair = null - found via real-device testing, not by this test.
@Test
fun shouldApplyAddAccountDiffWithTronSecrets() = runBlocking {
LocalAccountsMocker.setupMocks(metaAccountDao) {}
SecretStoreMocker.setupMocks(secretStore) {}
allChainsAreEvm(false)
val localBackup = buildTestCloudBackup {
publicData { }
privateData { }
}
val bytes32 = bytes32of(0)
val tronAddressBytes = bytes20of(1)
val tronDerivationPath = "//44//195//0/0/0"
val cloudBackup = buildTestCloudBackup {
publicData {
wallet(walletUUid(0)) {
substrateAccountId(bytes32)
substrateCryptoType(CryptoType.SR25519)
substratePublicKey(bytes32)
tronPublicKey(bytes32)
tronAddress(tronAddressBytes)
}
}
privateData {
wallet(walletUUid(0)) {
entropy(bytes32)
substrate {
seed(bytes32)
keypair(KeyPairSecrets(bytes32, bytes32, bytes32))
}
tron {
derivationPath(tronDerivationPath)
keypair(KeyPairSecrets(bytes32, bytes32, nonce = null))
}
}
}
}
val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal())
facade.applyBackupDiff(diff, cloudBackup)
verify(metaAccountDao).insertMetaAccount(metaAccountWithTronAddress(tronAddressBytes))
verify(secretStore).putMetaAccountSecrets(eq(0), metaAccountSecretsWithTronDerivationPath(tronDerivationPath))
}
@Test
fun shouldApplyRemoveAccountDiff(): Unit = runBlocking {
allChainsAreEvm(false)
@@ -1205,6 +1265,14 @@ class RealLocalAccountsCloudBackupFacadeTest {
return argThat { it.globallyUniqueId == id }
}
private fun metaAccountWithTronAddress(tronAddress: ByteArray): MetaAccountLocal {
return argThat { it.tronAddress.contentEquals(tronAddress) }
}
private fun metaAccountSecretsWithTronDerivationPath(derivationPath: String): EncodableStruct<MetaAccountSecrets> {
return argThat { it.tronDerivationPath == derivationPath && it.tronKeypair != null }
}
private suspend fun verifyNoAdditionalSecretsInserted() {
verify(secretStore, never()).putAdditionalMetaAccountSecret(anyLong(), any(), any())
}
@@ -23,7 +23,19 @@ data class CloudBackup(
val ethereumPublicKey: ByteArray?,
val name: String,
val type: Type,
val chainAccounts: Set<ChainAccountInfo>
val chainAccounts: Set<ChainAccountInfo>,
// All three below are nullable and defaulted so that Gson deserializing a backup written before that
// particular chain family existed - which has no such field in its JSON at all - lands on null here
// rather than failing. Solana/bitcoin have no derivation anywhere in this codebase yet (no native
// support, unlike Tron) - the fields are reserved now purely so that adding that support later never
// needs another silent-drop-prone trip through every call site that touches this schema, the way Tron
// did when it was bolted onto a schema that only knew about substrate/ethereum.
val tronAddress: ByteArray? = null,
val tronPublicKey: ByteArray? = null,
val solanaAddress: ByteArray? = null,
val solanaPublicKey: ByteArray? = null,
val bitcoinAddress: ByteArray? = null,
val bitcoinPublicKey: ByteArray? = null,
) : Identifiable {
override val identifier: String = walletId
@@ -72,7 +84,13 @@ data class CloudBackup(
ethereumPublicKey.contentEquals(other.ethereumPublicKey) &&
name == other.name &&
type == other.type &&
chainAccounts == other.chainAccounts
chainAccounts == other.chainAccounts &&
tronAddress.contentEquals(other.tronAddress) &&
tronPublicKey.contentEquals(other.tronPublicKey) &&
solanaAddress.contentEquals(other.solanaAddress) &&
solanaPublicKey.contentEquals(other.solanaPublicKey) &&
bitcoinAddress.contentEquals(other.bitcoinAddress) &&
bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)
}
override fun hashCode(): Int {
@@ -85,6 +103,12 @@ data class CloudBackup(
result = 31 * result + name.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + chainAccounts.hashCode()
result = 31 * result + (tronAddress?.contentHashCode() ?: 0)
result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0)
result = 31 * result + (solanaAddress?.contentHashCode() ?: 0)
result = 31 * result + (solanaPublicKey?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0)
result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0)
result = 31 * result + identifier.hashCode()
return result
}
@@ -100,6 +124,11 @@ data class CloudBackup(
val substrate: SubstrateSecrets?,
val ethereum: EthereumSecrets?,
val chainAccounts: List<ChainAccountSecrets>,
// See the matching comment on WalletPublicInfo: tron is fully wired end to end, solana/bitcoin are
// schema-only reservations until native derivation for them exists.
val tron: TronSecrets? = null,
val solana: SolanaSecrets? = null,
val bitcoin: BitcoinSecrets? = null,
) : Identifiable {
override val identifier: String = walletId
@@ -118,6 +147,9 @@ data class CloudBackup(
if (substrate != other.substrate) return false
if (ethereum != other.ethereum) return false
if (chainAccounts != other.chainAccounts) return false
if (tron != other.tron) return false
if (solana != other.solana) return false
if (bitcoin != other.bitcoin) return false
return identifier == other.identifier
}
@@ -127,6 +159,9 @@ data class CloudBackup(
result = 31 * result + (substrate?.hashCode() ?: 0)
result = 31 * result + (ethereum?.hashCode() ?: 0)
result = 31 * result + chainAccounts.hashCode()
result = 31 * result + (tron?.hashCode() ?: 0)
result = 31 * result + (solana?.hashCode() ?: 0)
result = 31 * result + (bitcoin?.hashCode() ?: 0)
result = 31 * result + identifier.hashCode()
return result
}
@@ -201,6 +236,23 @@ data class CloudBackup(
val derivationPath: String?,
)
data class TronSecrets(
val keypair: KeyPairSecrets,
val derivationPath: String?,
)
// Reserved shape for when native Solana derivation is added - unused until then, see the class-level comment.
data class SolanaSecrets(
val keypair: KeyPairSecrets,
val derivationPath: String?,
)
// Reserved shape for when native Bitcoin derivation is added - unused until then, see the class-level comment.
data class BitcoinSecrets(
val keypair: KeyPairSecrets,
val derivationPath: String?,
)
data class KeyPairSecrets(
val publicKey: ByteArray,
val privateKey: ByteArray,
@@ -234,5 +286,5 @@ data class CloudBackup(
}
fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean {
return entropy == null && substrate == null && ethereum == null && chainAccounts.isEmpty()
return entropy == null && substrate == null && ethereum == null && tron == null && chainAccounts.isEmpty()
}
@@ -7,6 +7,7 @@ import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.EthereumSecrets
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.KeyPairSecrets
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.SubstrateSecrets
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.TronSecrets
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType
@@ -99,6 +100,7 @@ class WalletPrivateInfoBuilder(
private var substrate: SubstrateSecrets? = null
private var ethereum: EthereumSecrets? = null
private var tron: TronSecrets? = null
private val chainAccounts = mutableListOf<ChainAccountSecrets>()
@@ -114,6 +116,10 @@ class WalletPrivateInfoBuilder(
ethereum = BackupEthereumSecretsBuilder().apply(builder).build()
}
fun tron(builder: BackupTronSecretsBuilder.() -> Unit) {
tron = BackupTronSecretsBuilder().apply(builder).build()
}
fun chainAccount(accountId: AccountId, builder: (BackupChainAccountSecretsBuilder.() -> Unit)? = null) {
val element = BackupChainAccountSecretsBuilder(accountId).apply { builder?.invoke(this) }.build()
chainAccounts.add(element)
@@ -126,6 +132,7 @@ class WalletPrivateInfoBuilder(
substrate = substrate,
ethereum = ethereum,
chainAccounts = chainAccounts,
tron = tron,
)
}
}
@@ -173,6 +180,25 @@ class BackupEthereumSecretsBuilder {
}
}
@CloudBackupBuildDsl
class BackupTronSecretsBuilder {
private var _keypair: KeyPairSecrets? = null
private var _derivationPath: String? = null
fun derivationPath(value: String?) {
_derivationPath = value
}
fun keypair(keypair: KeyPairSecrets) {
_keypair = keypair
}
fun build(): TronSecrets {
return TronSecrets(requireNotNull(_keypair), _derivationPath)
}
}
@CloudBackupBuildDsl
class BackupChainAccountSecretsBuilder(private val accountId: AccountId) {
@@ -223,6 +249,8 @@ class WalletPublicInfoBuilder(
private var _substrateAccountId: ByteArray? = null
private var _ethereumPublicKey: ByteArray? = null
private var _ethereumAddress: ByteArray? = null
private var _tronPublicKey: ByteArray? = null
private var _tronAddress: ByteArray? = null
private var _name: String = ""
private var _isSelected: Boolean = false
private var _type: WalletPublicInfo.Type = WalletPublicInfo.Type.SECRETS
@@ -252,6 +280,14 @@ class WalletPublicInfoBuilder(
_ethereumAddress = value
}
fun tronPublicKey(value: ByteArray?) {
_tronPublicKey = value
}
fun tronAddress(value: ByteArray?) {
_tronAddress = value
}
fun name(value: String) {
_name = value
}
@@ -274,7 +310,9 @@ class WalletPublicInfoBuilder(
ethereumPublicKey = _ethereumPublicKey,
name = _name,
type = _type,
chainAccounts = chainAccounts.toSet()
chainAccounts = chainAccounts.toSet(),
tronAddress = _tronAddress,
tronPublicKey = _tronPublicKey,
)
}
}