fix: make Tron address backfill self-healing instead of one-shot

TronAddressBackfillMigration was gated behind a global SharedPreferences
flag ("has this migration ever run on this install") rather than checking
per-account whether a Tron keypair is actually missing. Once that flag was
set - even by a run that found nothing to fix, or partially failed - the
migration never ran again on that install, so an account that lost its Tron
keypair some other way (the cloud-backup schema gap fixed in c78b325) could
never be repaired without a fresh reinstall.

backfillIfNeeded() already does a cheap, correct per-account check (skips
instantly if the account already has a keypair, isn't SECRETS-type, has no
entropy, etc.), so there's no need for an outer one-shot gate at all - just
run it unconditionally on every app start.
This commit is contained in:
2026-07-11 05:29:11 -07:00
parent c78b325e6d
commit e1679367d7
4 changed files with 18 additions and 44 deletions
@@ -80,11 +80,9 @@ class AccountDataSourceImpl(
accountDataMigration.migrate(::saveSecuritySource) accountDataMigration.migrate(::saveSecuritySource)
} }
Log.d("AccountDataSourceImpl", "about to check tronAddressBackfillMigration") Log.d("AccountDataSourceImpl", "about to run tronAddressBackfillMigration")
if (tronAddressBackfillMigration.migrationNeeded()) { tronAddressBackfillMigration.migrate()
tronAddressBackfillMigration.migrate()
}
Log.d("AccountDataSourceImpl", "migrations block done") Log.d("AccountDataSourceImpl", "migrations block done")
@@ -12,7 +12,6 @@ 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.substrateDerivationPath
import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
import io.novafoundation.nova.core_db.dao.MetaAccountDao import io.novafoundation.nova.core_db.dao.MetaAccountDao
import io.novafoundation.nova.core_db.dao.updateMetaAccount import io.novafoundation.nova.core_db.dao.updateMetaAccount
@@ -23,19 +22,22 @@ import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private const val PREFS_TRON_ADDRESS_BACKFILL_DONE = "tron_address_backfill_1_1_3"
private const val TAG = "TronAddressBackfill" private const val TAG = "TronAddressBackfill"
/** /**
* One-time backfill for accounts created before Tron support existed. `73_74_AddTronSupport` (the migration * Idempotent, per-account backfill for accounts that don't yet have a Tron keypair - both accounts created
* that added `meta_accounts.tronPublicKey`/`tronAddress`) is, like every other migration in this codebase, pure * before Tron support existed, AND accounts whose Tron keypair was lost some other way (e.g. the cloud-backup
* `ALTER TABLE` - it never derives a value for pre-existing rows. `tronAddress` is otherwise only ever set once, * schema round trip that used to silently drop it before every wallet had a `tron` field to serialize into -
* at fresh-mnemonic-creation time in [io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory.metaAccountSecrets], * see CloudBackup.kt). `73_74_AddTronSupport` (the migration that added `meta_accounts.tronPublicKey`/
* so without this backfill `MetaAccount.hasAccountIn(tronChain)` (`tronAddress != null`) permanently returns * `tronAddress`) is, like every other migration in this codebase, pure `ALTER TABLE` - it never derives a value
* false for every pre-existing seed-derived wallet, which makes `BalancesUpdateSystem` skip Tron entirely for * for pre-existing rows.
* that account - no address, no balance, no send, with no error surfaced anywhere. Found via manual testing *
* against a real pre-Tron production wallet; no automated test catches this because every automated test * Deliberately has NO "have I already run once" flag: an earlier version of this class gated itself behind a
* creates a fresh (post-Tron) account. * 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: * Only touches accounts that are:
* - `Type.SECRETS` (mnemonic-derived) - watch-only/Ledger/Json/multisig/proxied accounts never had a * - `Type.SECRETS` (mnemonic-derived) - watch-only/Ledger/Json/multisig/proxied accounts never had a
@@ -52,18 +54,11 @@ private const val TAG = "TronAddressBackfill"
* same Tron address it would have gotten had it been created today, not a separately-reimplemented derivation. * same Tron address it would have gotten had it been created today, not a separately-reimplemented derivation.
*/ */
class TronAddressBackfillMigration( class TronAddressBackfillMigration(
private val preferences: Preferences,
private val secretStoreV2: SecretStoreV2, private val secretStoreV2: SecretStoreV2,
private val metaAccountDao: MetaAccountDao, private val metaAccountDao: MetaAccountDao,
private val accountSecretsFactory: AccountSecretsFactory, private val accountSecretsFactory: AccountSecretsFactory,
) { ) {
suspend fun migrationNeeded(): Boolean = withContext(Dispatchers.Default) {
val needed = !preferences.getBoolean(PREFS_TRON_ADDRESS_BACKFILL_DONE, false)
Log.d(TAG, "migrationNeeded = $needed")
needed
}
suspend fun migrate() = withContext(Dispatchers.Default) { suspend fun migrate() = withContext(Dispatchers.Default) {
val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS } 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 }}") Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}")
@@ -76,8 +71,7 @@ class TronAddressBackfillMigration(
} }
} }
preferences.putBoolean(PREFS_TRON_ADDRESS_BACKFILL_DONE, true) Log.d(TAG, "migrate() done")
Log.d(TAG, "migrate() done, flag persisted")
} }
private suspend fun backfillIfNeeded(account: MetaAccountLocal) { private suspend fun backfillIfNeeded(account: MetaAccountLocal) {
@@ -445,12 +445,11 @@ class AccountFeatureModule {
@Provides @Provides
@FeatureScope @FeatureScope
fun provideTronAddressBackfillMigration( fun provideTronAddressBackfillMigration(
preferences: Preferences,
secretStoreV2: SecretStoreV2, secretStoreV2: SecretStoreV2,
metaAccountDao: MetaAccountDao, metaAccountDao: MetaAccountDao,
accountSecretsFactory: AccountSecretsFactory, accountSecretsFactory: AccountSecretsFactory,
): TronAddressBackfillMigration { ): TronAddressBackfillMigration {
return TronAddressBackfillMigration(preferences, secretStoreV2, metaAccountDao, accountSecretsFactory) return TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
} }
@Provides @Provides
@@ -5,7 +5,6 @@ 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.SecretStoreV2
import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair
import io.novafoundation.nova.common.data.secrets.v2.tronKeypair import io.novafoundation.nova.common.data.secrets.v2.tronKeypair
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.utils.invoke import io.novafoundation.nova.common.utils.invoke
import io.novafoundation.nova.common.utils.tronAddressToAccountId import io.novafoundation.nova.common.utils.tronAddressToAccountId
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
@@ -17,7 +16,6 @@ import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder
import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator
import io.novasama.substrate_sdk_android.scale.EncodableStruct import io.novasama.substrate_sdk_android.scale.EncodableStruct
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -58,9 +56,6 @@ private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
@RunWith(MockitoJUnitRunner::class) @RunWith(MockitoJUnitRunner::class)
class TronAddressBackfillMigrationTest { class TronAddressBackfillMigrationTest {
@Mock
lateinit var preferences: Preferences
@Mock @Mock
lateinit var secretStoreV2: SecretStoreV2 lateinit var secretStoreV2: SecretStoreV2
@@ -79,19 +74,7 @@ class TronAddressBackfillMigrationTest {
fun setup() { fun setup() {
// Real factory, not a mock - the whole point of this test is to exercise the actual derivation. // Real factory, not a mock - the whole point of this test is to exercise the actual derivation.
val accountSecretsFactory = AccountSecretsFactory(jsonDecoder) val accountSecretsFactory = AccountSecretsFactory(jsonDecoder)
subject = TronAddressBackfillMigration(preferences, secretStoreV2, metaAccountDao, accountSecretsFactory) subject = TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
}
@Test
fun `migrationNeeded should reflect the persisted flag`(): Unit = runBlocking {
// The flag's preference key is a private implementation detail of the production class - matched via
// any() here rather than duplicating the literal key string, which would let this test pass even if
// that string silently drifted out of sync with the production code.
whenever(preferences.getBoolean(any(), Mockito.anyBoolean())).thenReturn(false)
assertTrue(subject.migrationNeeded())
whenever(preferences.getBoolean(any(), Mockito.anyBoolean())).thenReturn(true)
assertTrue(!subject.migrationNeeded())
} }
@Test @Test