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