mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 17:15:48 +00:00
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:
@@ -13,6 +13,16 @@ android {
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
// android.util.Log.* throws "Method ... not mocked" by default in plain JVM unit tests (no real
|
||||
// Android framework, no Robolectric) - TronAddressBackfillMigrationTest is the first test in this
|
||||
// module to exercise code that calls Log.d/Log.e. This makes those stubbed calls return harmless
|
||||
// defaults instead of throwing, which is what the framework's own stubs are meant for in this context.
|
||||
returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
+22
-3
@@ -19,6 +19,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
|
||||
@@ -93,6 +95,7 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
ethereum = baseSecrets.getEthereumBackupSecrets(),
|
||||
chainAccounts = emptyList(),
|
||||
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
|
||||
tron = baseSecrets.getTronBackupSecrets(),
|
||||
)
|
||||
|
||||
return CloudBackup(
|
||||
@@ -361,6 +364,7 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
ethereum = baseSecrets.getEthereumBackupSecrets(),
|
||||
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
|
||||
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
|
||||
tron = baseSecrets.getTronBackupSecrets(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -472,7 +476,9 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(),
|
||||
ethereumDerivationPath = ethereum?.derivationPath,
|
||||
bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(),
|
||||
bitcoinDerivationPath = bitcoin?.derivationPath
|
||||
bitcoinDerivationPath = bitcoin?.derivationPath,
|
||||
tronKeypair = tron?.keypair?.toLocalKeyPair(),
|
||||
tronDerivationPath = tron?.derivationPath
|
||||
)
|
||||
}
|
||||
|
||||
@@ -494,6 +500,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
|
||||
|
||||
@@ -537,7 +552,9 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
type = metaAccount.type.toBackupWalletType() ?: return null,
|
||||
chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) },
|
||||
bitcoinAddress = metaAccount.bitcoinAddress,
|
||||
bitcoinPublicKey = metaAccount.bitcoinPublicKey
|
||||
bitcoinPublicKey = metaAccount.bitcoinPublicKey,
|
||||
tronAddress = metaAccount.tronAddress,
|
||||
tronPublicKey = metaAccount.tronPublicKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -561,7 +578,9 @@ class RealLocalAccountsCloudBackupFacade(
|
||||
status = MetaAccountLocal.Status.ACTIVE,
|
||||
typeExtras = null,
|
||||
bitcoinAddress = bitcoinAddress,
|
||||
bitcoinPublicKey = bitcoinPublicKey
|
||||
bitcoinPublicKey = bitcoinPublicKey,
|
||||
tronAddress = tronAddress,
|
||||
tronPublicKey = tronPublicKey,
|
||||
).also {
|
||||
if (localIdOverwrite != null) {
|
||||
it.id = localIdOverwrite
|
||||
|
||||
+31
-7
@@ -31,6 +31,7 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTy
|
||||
import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData
|
||||
import io.novafoundation.nova.runtime.ext.accountIdOf
|
||||
@@ -65,17 +66,40 @@ class AccountDataSourceImpl(
|
||||
private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
|
||||
secretStoreV1: SecretStoreV1,
|
||||
accountDataMigration: AccountDataMigration,
|
||||
private val bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
|
||||
tronAddressBackfillMigration: TronAddressBackfillMigration,
|
||||
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
|
||||
) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
|
||||
|
||||
init {
|
||||
migrateIfNeeded(accountDataMigration)
|
||||
async { bitcoinAddressBackfillMigration.migrate() }
|
||||
}
|
||||
// Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin backfills read
|
||||
// accounts/secrets that the legacy migration may still be in the middle of writing for very old
|
||||
// (pre-MetaAccount) installs, and separate GlobalScope.launch calls give no ordering guarantee
|
||||
// relative to each other.
|
||||
async {
|
||||
Log.d("AccountDataSourceImpl", "migrations block starting")
|
||||
|
||||
private fun migrateIfNeeded(migration: AccountDataMigration) = async {
|
||||
if (migration.migrationNeeded()) {
|
||||
migration.migrate(::saveSecuritySource)
|
||||
if (accountDataMigration.migrationNeeded()) {
|
||||
accountDataMigration.migrate(::saveSecuritySource)
|
||||
}
|
||||
|
||||
Log.d("AccountDataSourceImpl", "about to run tronAddressBackfillMigration")
|
||||
|
||||
tronAddressBackfillMigration.migrate()
|
||||
|
||||
Log.d("AccountDataSourceImpl", "about to run bitcoinAddressBackfillMigration")
|
||||
|
||||
bitcoinAddressBackfillMigration.migrate()
|
||||
|
||||
Log.d("AccountDataSourceImpl", "migrations block done")
|
||||
|
||||
metaAccountDao.getMetaAccounts().forEach {
|
||||
Log.d(
|
||||
"TronDiag",
|
||||
"metaId=${it.id} name=${it.name} type=${it.type} isSelected=${it.isSelected} " +
|
||||
"tronAddress=${it.tronAddress?.joinToString("") { b -> "%02x".format(b) } ?: "NULL"} " +
|
||||
"tronPublicKey=${if (it.tronPublicKey != null) "present(${it.tronPublicKey!!.size}B)" else "NULL"}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
|
||||
|
||||
import android.util.Log
|
||||
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.ethereumDerivationPath
|
||||
import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
|
||||
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.tronKeypair
|
||||
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
|
||||
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||
import io.novafoundation.nova.core_db.dao.updateMetaAccount
|
||||
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
|
||||
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
|
||||
import io.novafoundation.nova.feature_account_impl.data.secrets.TRON_DEFAULT_DERIVATION_PATH
|
||||
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val TAG = "TronAddressBackfill"
|
||||
|
||||
/**
|
||||
* Idempotent, per-account backfill for accounts that don't yet have a Tron keypair - both accounts created
|
||||
* before Tron support existed, AND accounts whose Tron keypair was lost some other way (e.g. the cloud-backup
|
||||
* schema round trip that used to silently drop it before every wallet had a `tron` field to serialize into -
|
||||
* see CloudBackup.kt). `73_74_AddTronSupport` (the migration that added `meta_accounts.tronPublicKey`/
|
||||
* `tronAddress`) is, like every other migration in this codebase, pure `ALTER TABLE` - it never derives a value
|
||||
* for pre-existing rows.
|
||||
*
|
||||
* Deliberately has NO "have I already run once" flag: an earlier version of this class gated itself behind a
|
||||
* one-shot SharedPreferences flag, which meant that once it ran and marked itself done - even for an account
|
||||
* that legitimately still lacked a Tron keypair afterwards (e.g. because a *different*, since-fixed bug kept
|
||||
* re-losing it) - it would never run again for that account, ever, on that install. [backfillIfNeeded] is cheap
|
||||
* to call for an account that doesn't need it (a handful of null-checks, no derivation), so this just runs
|
||||
* unconditionally on every app start instead: self-healing by construction, no stuck-flag failure mode possible.
|
||||
*
|
||||
* Only touches accounts that are:
|
||||
* - `Type.SECRETS` (mnemonic-derived) - watch-only/Ledger/Json/multisig/proxied accounts never had a
|
||||
* Tron-capable mnemonic and are correctly left with `tronAddress == null` forever, same as they already are
|
||||
* for Ethereum.
|
||||
* - still holding their `Entropy` in [SecretStoreV2] - an account imported from a raw seed/keypair rather than
|
||||
* a mnemonic has no entropy either and is likewise correctly left alone.
|
||||
* - missing a `TronKeypair` - i.e. not already backfilled and not created after Tron support shipped.
|
||||
*
|
||||
* The Tron keypair is derived via [AccountSecretsFactory.chainAccountSecrets] with `isEthereum = true` (Tron
|
||||
* reuses the exact same secp256k1/BIP32 derivation as Ethereum, just under its own SLIP-44 coin-type-195 path -
|
||||
* see that class's own doc comment) at [TRON_DEFAULT_DERIVATION_PATH], the same call this codebase's own
|
||||
* `metaAccountSecrets()` makes for a fresh account - so a backfilled account ends up with byte-for-byte the
|
||||
* same Tron address it would have gotten had it been created today, not a separately-reimplemented derivation.
|
||||
*/
|
||||
class TronAddressBackfillMigration(
|
||||
private val secretStoreV2: SecretStoreV2,
|
||||
private val metaAccountDao: MetaAccountDao,
|
||||
private val accountSecretsFactory: AccountSecretsFactory,
|
||||
) {
|
||||
|
||||
suspend fun migrate() = withContext(Dispatchers.Default) {
|
||||
val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS }
|
||||
Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}")
|
||||
|
||||
secretsAccounts.forEach { account ->
|
||||
try {
|
||||
backfillIfNeeded(account)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "migrate() done")
|
||||
}
|
||||
|
||||
private suspend fun backfillIfNeeded(account: MetaAccountLocal) {
|
||||
val secrets = secretStoreV2.getMetaAccountSecrets(account.id)
|
||||
if (secrets == null) {
|
||||
Log.d(TAG, "metaId=${account.id}: no stored secrets at all (watch-only/Ledger/etc) - skipping")
|
||||
return
|
||||
}
|
||||
val entropy = secrets.entropy
|
||||
if (entropy == null) {
|
||||
Log.d(TAG, "metaId=${account.id}: no entropy (raw-seed import, not a mnemonic) - skipping")
|
||||
return
|
||||
}
|
||||
if (secrets.tronKeypair != null) {
|
||||
Log.d(TAG, "metaId=${account.id}: already has a TronKeypair - skipping")
|
||||
return
|
||||
}
|
||||
val substrateCryptoType = account.substrateCryptoType
|
||||
if (substrateCryptoType == null) {
|
||||
Log.d(TAG, "metaId=${account.id}: substrateCryptoType is null - skipping")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "metaId=${account.id}: deriving Tron keypair")
|
||||
|
||||
val mnemonic = MnemonicCreator.fromEntropy(entropy).words
|
||||
|
||||
val tronChainSecrets = accountSecretsFactory.chainAccountSecrets(
|
||||
derivationPath = TRON_DEFAULT_DERIVATION_PATH,
|
||||
accountSource = AccountSecretsFactory.AccountSource.Mnemonic(substrateCryptoType, mnemonic),
|
||||
isEthereum = true
|
||||
).secrets
|
||||
|
||||
val tronKeypair = mapKeypairStructToKeypair(tronChainSecrets[ChainAccountSecrets.Keypair])
|
||||
|
||||
val updatedSecrets = MetaAccountSecrets(
|
||||
substrateKeyPair = mapKeypairStructToKeypair(secrets.substrateKeypair),
|
||||
entropy = secrets.entropy,
|
||||
substrateSeed = secrets.seed,
|
||||
substrateDerivationPath = secrets.substrateDerivationPath,
|
||||
ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair),
|
||||
ethereumDerivationPath = secrets.ethereumDerivationPath,
|
||||
tronKeypair = tronKeypair,
|
||||
tronDerivationPath = TRON_DEFAULT_DERIVATION_PATH
|
||||
)
|
||||
|
||||
secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets)
|
||||
|
||||
val tronAccountId = tronKeypair.publicKey.tronPublicKeyToAccountId()
|
||||
metaAccountDao.updateMetaAccount(account.id) { it.addTronAccount(tronKeypair.publicKey, tronAccountId) }
|
||||
|
||||
Log.d(TAG, "metaId=${account.id}: backfilled successfully, tronAddress set (${tronAccountId.size} bytes)")
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -142,12 +142,15 @@ class SecretsSigner(
|
||||
private suspend fun getKeypair(accountId: AccountId): Keypair {
|
||||
val chainsById = chainRegistry.chainsById()
|
||||
val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!!
|
||||
val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true
|
||||
val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true
|
||||
|
||||
return secretStoreV2.getKeypair(
|
||||
metaAccount = metaAccount,
|
||||
accountId = accountId,
|
||||
isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum,
|
||||
isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true
|
||||
isTronBased = isTronBased,
|
||||
isBitcoinBased = isBitcoinBased
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,11 +164,12 @@ class SecretsSigner(
|
||||
metaAccount: MetaAccount,
|
||||
accountId: AccountId,
|
||||
isEthereumBased: Boolean,
|
||||
isTronBased: Boolean = false,
|
||||
isBitcoinBased: Boolean = false,
|
||||
) = if (hasChainSecrets(metaAccount.id, accountId)) {
|
||||
getChainAccountKeypair(metaAccount.id, accountId)
|
||||
} else {
|
||||
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isBitcoinBased)
|
||||
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +179,11 @@ class SecretsSigner(
|
||||
return when {
|
||||
substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
||||
ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum
|
||||
// Tron and Bitcoin both reuse the exact same secp256k1 signing scheme as Ethereum - they just have
|
||||
// their own accountId (different SLIP-44 derivation path), so neither matches ethereumAccountId()
|
||||
// above and was falling through to the chainAccounts lookup, which doesn't cover them either ->
|
||||
// null -> NPE on `!!`.
|
||||
tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
|
||||
bitcoinAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum
|
||||
else -> {
|
||||
val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null
|
||||
|
||||
+13
@@ -105,6 +105,7 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Re
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration
|
||||
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
|
||||
import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory
|
||||
import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule
|
||||
@@ -403,6 +404,7 @@ class AccountFeatureModule {
|
||||
nodeDao: NodeDao,
|
||||
secretStoreV1: SecretStoreV1,
|
||||
accountDataMigration: AccountDataMigration,
|
||||
tronAddressBackfillMigration: TronAddressBackfillMigration,
|
||||
metaAccountDao: MetaAccountDao,
|
||||
secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
|
||||
secretStoreV2: SecretStoreV2,
|
||||
@@ -419,6 +421,7 @@ class AccountFeatureModule {
|
||||
secretsMetaAccountLocalFactory,
|
||||
secretStoreV1,
|
||||
accountDataMigration,
|
||||
tronAddressBackfillMigration,
|
||||
bitcoinAddressBackfillMigration
|
||||
)
|
||||
}
|
||||
@@ -452,6 +455,16 @@ class AccountFeatureModule {
|
||||
return AccountDataMigration(preferences, encryptedPreferences, accountDao)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideTronAddressBackfillMigration(
|
||||
secretStoreV2: SecretStoreV2,
|
||||
metaAccountDao: MetaAccountDao,
|
||||
accountSecretsFactory: AccountSecretsFactory,
|
||||
): TronAddressBackfillMigration {
|
||||
return TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideExternalAccountActions(
|
||||
|
||||
+8
-1
@@ -53,7 +53,10 @@ class RealSecretsMetaAccount(
|
||||
hasChainAccountIn(chain.id) -> {
|
||||
val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null
|
||||
|
||||
if (chain.isEthereumBased) {
|
||||
// Tron and Bitcoin both reuse the same secp256k1 keypair/signing as Ethereum - see
|
||||
// RealTronTransactionService/RealBitcoinTransactionService's use of
|
||||
// Signer.sign(MultiChainEncryption.Ethereum, ...).
|
||||
if (chain.isEthereumBased || chain.isTronBased || chain.isBitcoinBased) {
|
||||
MultiChainEncryption.Ethereum
|
||||
} else {
|
||||
MultiChainEncryption.substrateFrom(cryptoType)
|
||||
@@ -62,6 +65,10 @@ class RealSecretsMetaAccount(
|
||||
|
||||
chain.isEthereumBased -> MultiChainEncryption.Ethereum
|
||||
|
||||
chain.isTronBased -> MultiChainEncryption.Ethereum
|
||||
|
||||
chain.isBitcoinBased -> MultiChainEncryption.Ethereum
|
||||
|
||||
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -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())
|
||||
}
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration
|
||||
|
||||
import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema
|
||||
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.mapKeypairStructToKeypair
|
||||
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
|
||||
import io.novafoundation.nova.common.utils.invoke
|
||||
import io.novafoundation.nova.common.utils.tronAddressToAccountId
|
||||
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
|
||||
import io.novafoundation.nova.core.model.CryptoType
|
||||
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||
import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal
|
||||
import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory
|
||||
import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder
|
||||
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
|
||||
import io.novasama.substrate_sdk_android.scale.EncodableStruct
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.ArgumentMatcher
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.junit.MockitoJUnitRunner
|
||||
|
||||
// Same guaranteed-non-null-return wrappers as RealTronTransactionServiceTest, and for the same reason: the
|
||||
// shared test_shared eq()/any() helpers crash with "eq(...) must not be null" once Mockito's genuinely-null
|
||||
// runtime return flows into a Kotlin non-null-typed parameter - see that test's class doc for the full writeup.
|
||||
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> any(): T {
|
||||
Mockito.any<T>()
|
||||
return null as T
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> argThat(matcher: (T) -> Boolean): T {
|
||||
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
|
||||
return null as T
|
||||
}
|
||||
|
||||
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
|
||||
|
||||
/**
|
||||
* This is the money-safety-critical half of the Tron backfill fix (see TronAddressBackfillMigration's class
|
||||
* doc for the full context): a wrong derivation here would silently give a pre-existing wallet a Tron address
|
||||
* it does NOT actually control, or fail to skip an account it shouldn't touch. Uses a real (non-mocked)
|
||||
* AccountSecretsFactory so the actual BIP32/secp256k1 derivation math runs for real, and asserts against the
|
||||
* same live-TronGrid-cross-validated reference address TronDerivationTest already established for the standard
|
||||
* BIP39 test mnemonic, so this test and that one are pinned to the same known-good vector.
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner::class)
|
||||
class TronAddressBackfillMigrationTest {
|
||||
|
||||
@Mock
|
||||
lateinit var secretStoreV2: SecretStoreV2
|
||||
|
||||
@Mock
|
||||
lateinit var metaAccountDao: MetaAccountDao
|
||||
|
||||
@Mock
|
||||
lateinit var jsonDecoder: JsonDecoder
|
||||
|
||||
private lateinit var subject: TronAddressBackfillMigration
|
||||
|
||||
private val testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
private val expectedTronAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
// Real factory, not a mock - the whole point of this test is to exercise the actual derivation.
|
||||
val accountSecretsFactory = AccountSecretsFactory(jsonDecoder)
|
||||
subject = TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate should derive and persist the well-known reference Tron address for a pre-existing mnemonic account`(): Unit = runBlocking {
|
||||
val account = secretsAccount(id = 42, substrateCryptoType = CryptoType.SR25519)
|
||||
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = null)
|
||||
|
||||
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||
whenever(metaAccountDao.getMetaAccount(eq(42L))).thenReturn(account)
|
||||
whenever(secretStoreV2.getMetaAccountSecrets(eq(42L))).thenReturn(secrets)
|
||||
|
||||
subject.migrate()
|
||||
|
||||
verify(metaAccountDao).updateMetaAccount(
|
||||
argThat<MetaAccountLocal> { updated ->
|
||||
updated.id == 42L && updated.tronAddress.contentEquals(expectedTronAccountId)
|
||||
}
|
||||
)
|
||||
|
||||
verify(secretStoreV2).putMetaAccountSecrets(
|
||||
eq(42L),
|
||||
argThat<EncodableStruct<MetaAccountSecrets>> { updatedSecrets ->
|
||||
val tronKeypairStruct = updatedSecrets.tronKeypair
|
||||
tronKeypairStruct != null &&
|
||||
mapKeypairStructToKeypair(tronKeypairStruct).publicKey.tronPublicKeyToAccountId().contentEquals(expectedTronAccountId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate should skip an account that already has a Tron keypair`(): Unit = runBlocking {
|
||||
val account = secretsAccount(id = 7, substrateCryptoType = CryptoType.SR25519)
|
||||
val existingTronKeypair = KeyPairSchema { keypair ->
|
||||
keypair[KeyPairSchema.PublicKey] = ByteArray(33) { 9 }
|
||||
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 8 }
|
||||
keypair[KeyPairSchema.Nonce] = null
|
||||
}
|
||||
val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = existingTronKeypair)
|
||||
|
||||
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||
whenever(secretStoreV2.getMetaAccountSecrets(eq(7L))).thenReturn(secrets)
|
||||
|
||||
subject.migrate()
|
||||
|
||||
verify(metaAccountDao, never()).updateMetaAccount(any())
|
||||
// metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong().
|
||||
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate should skip an account with no entropy (raw-seed import, not a mnemonic)`(): Unit = runBlocking {
|
||||
val account = secretsAccount(id = 11, substrateCryptoType = CryptoType.SR25519)
|
||||
val secrets = accountSecrets(entropy = null, tronKeypair = null)
|
||||
|
||||
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||
whenever(secretStoreV2.getMetaAccountSecrets(eq(11L))).thenReturn(secrets)
|
||||
|
||||
subject.migrate()
|
||||
|
||||
verify(metaAccountDao, never()).updateMetaAccount(any())
|
||||
// metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong().
|
||||
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrate should skip an account with no stored secrets at all (watch-only, Ledger, etc)`(): Unit = runBlocking {
|
||||
val account = secretsAccount(id = 13, substrateCryptoType = CryptoType.SR25519)
|
||||
|
||||
whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account))
|
||||
whenever(secretStoreV2.getMetaAccountSecrets(eq(13L))).thenReturn(null)
|
||||
|
||||
subject.migrate()
|
||||
|
||||
verify(metaAccountDao, never()).updateMetaAccount(any())
|
||||
// metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong().
|
||||
verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any())
|
||||
}
|
||||
|
||||
private fun secretsAccount(id: Long, substrateCryptoType: CryptoType): MetaAccountLocal {
|
||||
return MetaAccountLocal(
|
||||
substratePublicKey = ByteArray(32) { 1 },
|
||||
substrateCryptoType = substrateCryptoType,
|
||||
substrateAccountId = ByteArray(32) { 2 },
|
||||
ethereumPublicKey = null,
|
||||
ethereumAddress = null,
|
||||
name = "Test account",
|
||||
parentMetaId = null,
|
||||
isSelected = true,
|
||||
position = 0,
|
||||
type = MetaAccountLocal.Type.SECRETS,
|
||||
status = MetaAccountLocal.Status.ACTIVE,
|
||||
globallyUniqueId = "test-guid-$id",
|
||||
typeExtras = null
|
||||
).also { it.id = id }
|
||||
}
|
||||
|
||||
private fun accountSecrets(entropy: ByteArray?, tronKeypair: EncodableStruct<KeyPairSchema>?): EncodableStruct<MetaAccountSecrets> {
|
||||
val dummySubstrateKeypair = KeyPairSchema { keypair ->
|
||||
keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 5 }
|
||||
keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 6 }
|
||||
keypair[KeyPairSchema.Nonce] = ByteArray(8) { 7 }
|
||||
}
|
||||
|
||||
return MetaAccountSecrets(
|
||||
substrateKeyPair = mapKeypairStructToKeypair(dummySubstrateKeypair),
|
||||
entropy = entropy,
|
||||
substrateSeed = null,
|
||||
substrateDerivationPath = null,
|
||||
ethereumKeypair = null,
|
||||
ethereumDerivationPath = null,
|
||||
tronKeypair = tronKeypair?.let(::mapKeypairStructToKeypair),
|
||||
tronDerivationPath = null
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user