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
+3 -1
View File
@@ -16,10 +16,12 @@ android {
buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/chains.json\""
buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/details\""
buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\""
buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/chains_for_testBalance.json\""
buildConfigField "String", "TEST_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/pezkuwi_assets_for_testBalance.json\""
buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY")
buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY")
buildConfigField "String", "TRONGRID_API_KEY", readStringSecret("TRONGRID_API_KEY")
}
buildTypes {
@@ -39,6 +39,7 @@ import io.novasama.substrate_sdk_android.wsrpc.SocketService
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
import org.web3j.protocol.http.HttpService
import javax.inject.Provider
@@ -162,13 +163,17 @@ class ChainRegistryModule {
bulkRetriever: BulkRetriever,
connectionSecrets: ConnectionSecrets,
web3ApiFactory: Web3ApiFactory,
httpClient: OkHttpClient,
) = NodeHealthStateTesterFactory(
socketProvider,
connectionSecrets,
bulkRetriever,
web3ApiFactory,
httpClient
// A short-lived, minimally-configured client is enough for a health-check ping - unlike Web3ApiFactory's
// client, this never needs to survive/reuse connections across a long-lived RPC session.
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
)
@Provides
@@ -10,14 +10,11 @@ import io.novafoundation.nova.core_db.dao.ChainAssetDao
import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.dao.StorageDao
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import okhttp3.OkHttpClient
interface RuntimeDependencies {
fun networkApiCreator(): NetworkApiCreator
fun okHttpClient(): OkHttpClient
fun socketServiceCreator(): SocketService
fun gson(): Gson
@@ -19,6 +19,7 @@ import io.novafoundation.nova.common.utils.formatNamed
import io.novafoundation.nova.common.utils.isValidBitcoinAddress
import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.common.utils.emptyTronAccountId
import io.novafoundation.nova.common.utils.isValidTronAddress
import io.novafoundation.nova.common.utils.substrateAccountId
import io.novafoundation.nova.common.utils.toBitcoinAddress
import io.novafoundation.nova.common.utils.toTronAddress
@@ -372,7 +373,13 @@ fun Chain.isValidAddress(address: String): Boolean {
return runCatching {
when {
isBitcoinBased -> address.isValidBitcoinAddress()
// Tron addresses are Base58Check(0x41 ++ accountId), not SS58 or plain 0x-hex - neither of the two
// branches below would ever accept them, so this needs its own dedicated check.
isTronBased -> address.isValidTronAddress()
isEthereumBased -> address.asEthereumAddress().isValid()
else -> {
address.toAccountId() // verify supplied address can be converted to account id
@@ -492,6 +499,7 @@ object ChainGeneses {
object ChainIds {
const val ETHEREUM = "$EIP_155_PREFIX:1"
const val TRON = "tron:0x2b6653dc"
const val MOONBEAM = ChainGeneses.MOONBEAM
const val MOONRIVER = ChainGeneses.MOONRIVER
@@ -503,6 +511,33 @@ val Chain.Companion.Geneses
val Chain.Companion.Ids
get() = ChainIds
/**
* A short, user-facing token-standard label for chains where disambiguating "which token standard is this"
* is actually useful (multiple ecosystems all issue their own USDT/USDC etc., so a bare chain name isn't
* always enough context). Deliberately NOT derived from [Chain.Asset.Type] (e.g. every Statemine-type chain
* would get the same label) - this is chain-specific by design, matching exactly which labels are
* recognizable/expected by users (PEZ-20, ERC-20, TRC-20), not a mechanical one-label-per-asset-type mapping.
*/
val Chain.assetStandardLabelOrNull: String?
get() = when {
genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> "PEZ-20"
id == Chain.Ids.ETHEREUM -> "ERC-20"
id == Chain.Ids.TRON -> "TRC-20"
else -> null
}
/**
* Chain display name with its token-standard label appended where [assetStandardLabelOrNull] applies, e.g.
* "Ethereum (ERC-20)". Shared across every screen that lists the same token symbol once per chain (the
* Send/Receive/etc. network picker, the main balance list's per-token chain breakdown) - a bare chain name
* alone doesn't convey which issuance this is when multiple ecosystems share the same symbol.
*/
fun Chain.displayNameWithAssetStandard(): String {
val standardLabel = assetStandardLabelOrNull ?: return name
return "$name ($standardLabel)"
}
fun Chain.Asset.requireStatemine(): Type.Statemine {
require(type is Type.Statemine)
@@ -3,19 +3,23 @@ package io.novafoundation.nova.runtime.ext
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
val Chain.mainChainsFirstAscendingOrder
get() = when (genesisHash) {
get() = when {
// Pezkuwi ecosystem first
Chain.Geneses.PEZKUWI -> 0
Chain.Geneses.PEZKUWI_ASSET_HUB -> 1
Chain.Geneses.PEZKUWI_PEOPLE -> 2
genesisHash == Chain.Geneses.PEZKUWI -> 0
genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> 1
genesisHash == Chain.Geneses.PEZKUWI_PEOPLE -> 2
// Then Polkadot ecosystem
Chain.Geneses.POLKADOT -> 3
Chain.Geneses.POLKADOT_ASSET_HUB -> 4
genesisHash == Chain.Geneses.POLKADOT -> 3
genesisHash == Chain.Geneses.POLKADOT_ASSET_HUB -> 4
// Then Kusama ecosystem
Chain.Geneses.KUSAMA -> 5
Chain.Geneses.KUSAMA_ASSET_HUB -> 6
genesisHash == Chain.Geneses.KUSAMA -> 5
genesisHash == Chain.Geneses.KUSAMA_ASSET_HUB -> 6
// Then Ethereum, then Tron - not identified by genesisHash (that's substrate-only), so this can't
// stay a `when (genesisHash)` subject match once these two are added
id == Chain.Ids.ETHEREUM -> 7
id == Chain.Ids.TRON -> 8
// Everything else
else -> 7
else -> 9
}
val Chain.testnetsLastAscendingOrder
@@ -10,7 +10,14 @@ val TokenSymbol.mainTokensFirstAscendingOrder
"DOT" -> 3
"KSM" -> 4
"USDC" -> 5
else -> 6
"TRX" -> 6
"BTC" -> 7
"ETH" -> 8
"BNB" -> 9
"AVAX" -> 10
"LINK" -> 11
"TAO" -> 12
else -> 13
}
val TokenSymbol.alphabeticalOrder
@@ -7,7 +7,7 @@ import io.novafoundation.nova.common.utils.RuntimeContext
import io.novafoundation.nova.common.utils.diffed
import io.novafoundation.nova.common.utils.filterList
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.common.utils.mapListNotNull
import io.novafoundation.nova.common.utils.mapNotNullToSet
import io.novafoundation.nova.common.utils.provideContext
import io.novafoundation.nova.common.utils.removeHexPrefix
@@ -41,6 +41,7 @@ import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchro
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -67,14 +68,37 @@ class ChainRegistry(
private val runtimeSyncService: RuntimeSyncService,
private val web3ApiPool: Web3ApiPool,
private val gson: Gson
) : CoroutineScope by CoroutineScope(Dispatchers.Default) {
// SupervisorJob, not the plain Job a bare CoroutineScope(Dispatchers.Default) would give: without it, an
// uncaught exception in ANY coroutine sharing this scope (e.g. currentChains'/chainsById's shareIn, or any
// launch{} below) cancels every sibling, including the other one - a single malformed/leftover chain row
// would then permanently kill sync for every chain, not just the offending one.
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
val currentChains = chainDao.joinChainInfoFlow()
.mapList { mapChainLocalToChain(it, gson) }
// mapListNotNull, not mapList: mapChainLocalToChain() can throw on a single malformed row (e.g. a
// gson.fromJson() failure on the chain's `additional` JSON blob) - since this whole step runs as ONE
// transform over the ENTIRE chain list, one bad chain would previously throw out of this operator and
// permanently kill this Eagerly-shared flow for every chain, not just the offending one. Skip and log
// instead, matching the per-chain isolation already applied to registerChain/unregisterChain below.
.mapListNotNull { chainLocal ->
runCatching { mapChainLocalToChain(chainLocal, gson) }
.onFailure { Log.e(LOG_TAG, "Failed to map chain ${chainLocal.chain.id} (${chainLocal.chain.name}) from local DB", it) }
.getOrNull()
}
.diffed()
.map { diff ->
diff.removed.forEach { unregisterChain(it) }
diff.newOrUpdated.forEach { chain -> registerChain(chain) }
// Each chain's register/unregister is isolated: one malformed/leftover row (e.g. a chain persisted
// as disabled from an earlier session) must not throw out of this operator and kill this flow for
// every other chain - shareIn(..., Eagerly) never restarts once its upstream completes/throws, so
// any single unhandled exception here would silently and permanently break sync for the whole app.
diff.removed.forEach { chain ->
runCatching { unregisterChain(chain) }
.onFailure { Log.e(LOG_TAG, "Failed to unregister chain ${chain.name} (${chain.id})", it) }
}
diff.newOrUpdated.forEach { chain ->
runCatching { registerChain(chain) }
.onFailure { Log.e(LOG_TAG, "Failed to register chain ${chain.name} (${chain.id})", it) }
}
diff.all
}
@@ -1,7 +1,9 @@
package io.novafoundation.nova.runtime.multiNetwork.asset
import android.util.Log
import com.google.gson.Gson
import io.novafoundation.nova.common.utils.CollectionDiffer
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.retryUntilDone
import io.novafoundation.nova.core_db.dao.ChainAssetDao
import io.novafoundation.nova.core_db.dao.ChainDao
@@ -40,6 +42,18 @@ class EvmAssetsSyncService(
new.copy(enabled = old?.enabled ?: ENABLED_DEFAULT_BOOL)
}
// Same defensive guard as ChainSyncService: a transient upstream issue can make the fetch return
// successfully with a suspiciously small/empty list. Diffing that against a populated local DB would
// delete most or all of the user's ERC20 tokens (e.g. USDT-ERC20) - skip instead of wiping good data.
if (oldAssets.isNotEmpty() && newAssets.size < oldAssets.size / 2) {
Log.e(
LOG_TAG,
"Refusing to apply EVM asset sync: remote returned ${newAssets.size} assets vs ${oldAssets.size} currently stored " +
"(would remove more than half). Likely a transient fetch issue - skipping this sync cycle."
)
return
}
val diff = CollectionDiffer.findDiff(newAssets, oldAssets, forceUseNewItems = false)
chainAssetDao.updateAssets(diff)
}
@@ -1,7 +1,9 @@
package io.novafoundation.nova.runtime.multiNetwork.chain
import android.util.Log
import com.google.gson.Gson
import io.novafoundation.nova.common.utils.CollectionDiffer
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.retryUntilDone
import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.dao.FullAssetIdLocal
@@ -40,17 +42,48 @@ class ChainSyncService(
val remoteChains = retryUntilDone { chainFetcher.getChains() }
val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) }
val newAssets = remoteChains.flatMap { chain ->
chain.assets.map {
val fullAssetId = FullAssetIdLocal(chain.chainId, it.assetId)
val oldAsset = associatedOldAssets[fullAssetId]
mapRemoteAssetToLocal(chain, it, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL)
// A transient upstream issue (CDN hiccup, regional network filtering, a bad publish) can make
// chainFetcher.getChains() return successfully with a suspiciously small/empty list instead of
// throwing. Applying that as a diff against a populated local DB would delete most or all of the
// user's chains/assets - not a sync failure, but active data loss, for something that self-heals on
// the next successful sync if we just skip applying it. Only guard when we HAD data: an empty result
// on a genuinely first-ever sync is normal and must proceed.
if (oldChains.isNotEmpty() && remoteChains.size < oldChains.size / 2) {
Log.e(
LOG_TAG,
"Refusing to apply chain sync: remote returned ${remoteChains.size} chains vs ${oldChains.size} currently stored " +
"(would remove more than half). Likely a transient fetch issue - skipping this sync cycle."
)
return@withContext
}
// One malformed/incompatible chain (a new field the app's mapper doesn't understand yet, a bad
// publish, etc.) must not take down sync for every other chain - a plain .map{} here means a single
// throwing chain aborts before chainDao.applyDiff() is ever called, leaving a brand new install with
// zero locally-cached chains forever (a completely empty tokens list), since nothing else in this
// function ever gets a chance to run. Isolate failures per chain, and per asset within a chain that
// otherwise mapped fine, instead.
val remoteChainsWithLocal = remoteChains.mapNotNull { chainRemote ->
runCatching { chainRemote to mapRemoteChainToLocal(chainRemote, oldChainsById[chainRemote.chainId], source = ChainLocal.Source.DEFAULT, gson) }
.onFailure { Log.e(LOG_TAG, "Failed to map remote chain ${chainRemote.chainId} (${chainRemote.name}), skipping it for this sync cycle", it) }
.getOrNull()
}
val newChains = remoteChainsWithLocal.map { (_, chainLocal) -> chainLocal }
val newAssets = remoteChainsWithLocal.flatMap { (chain, _) ->
chain.assets.mapNotNull { assetRemote ->
runCatching {
val fullAssetId = FullAssetIdLocal(chain.chainId, assetRemote.assetId)
val oldAsset = associatedOldAssets[fullAssetId]
mapRemoteAssetToLocal(chain, assetRemote, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL)
}.onFailure {
Log.e(LOG_TAG, "Failed to map asset ${assetRemote.assetId} (${assetRemote.symbol}) on chain ${chain.chainId}, skipping it", it)
}.getOrNull()
}
}
val newNodes = remoteChains.flatMap(::mapRemoteNodesToLocal)
val newExplorers = remoteChains.flatMap(::mapRemoteExplorersToLocal)
val newExternalApis = remoteChains.flatMap(::mapExternalApisToLocal)
val newNodes = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteNodesToLocal(chain) }
val newExplorers = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteExplorersToLocal(chain) }
val newExternalApis = remoteChainsWithLocal.flatMap { (chain, _) -> mapExternalApisToLocal(chain) }
val newNodeSelectionPreferences = nodeSelectionPreferencesFor(newChains, oldNodeSelectionPreferences)
val chainsDiff = CollectionDiffer.findDiff(newChains, oldChains, forceUseNewItems = false)
@@ -27,6 +27,11 @@ class NodeHealthStateTesterFactory(
httpClient = httpClient
)
chain.isTronBased -> TronNodeHealthStateTester(
node = node,
httpClient = httpClient
)
chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester(
chain = chain,
socketService = socketServiceProvider.get(),
@@ -0,0 +1,41 @@
package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import kotlin.time.ExperimentalTime
import kotlin.time.measureTime
/**
* TronGrid speaks a plain REST API, not Ethereum JSON-RPC - reusing [EthereumNodeHealthStateTester] against it
* (as this codebase used to, before Tron nodes were included in health checks at all) would send an
* `eth_getBalance` call TronGrid doesn't understand, always reporting the node as unreachable regardless of its
* actual health. `GET /wallet/getchainparameters` needs no account/address context and is cheap on TronGrid's
* side, making it a good generic liveness ping - same endpoint this codebase already uses elsewhere
* (`TronGridApi.getChainParameters`), just called directly here since `runtime` cannot depend on
* `feature-wallet-impl` (wrong direction) to reuse that Retrofit interface.
*/
class TronNodeHealthStateTester(
private val node: Chain.Node,
private val httpClient: OkHttpClient,
) : NodeHealthStateTester {
@OptIn(ExperimentalTime::class)
override suspend fun testNodeHealthState(): Result<Long> = withContext(Dispatchers.IO) {
runCatching {
val request = Request.Builder()
.url("${node.unformattedUrl.trimEnd('/')}/wallet/getchainparameters")
.build()
val duration = measureTime {
httpClient.newCall(request).execute().use { response ->
check(response.isSuccessful) { "HTTP ${response.code}" }
}
}
duration.inWholeMilliseconds
}
}
}
@@ -14,7 +14,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.merge
import kotlin.coroutines.coroutineContext
@@ -24,35 +26,51 @@ abstract class ChainUpdaterGroupUpdateSystem(
private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
) : UpdateSystem {
// Callers (MultiChainUpdateSystem, SingleChainUpdateSystem) merge several chains' runUpdaters() results
// into one flow. chainRegistry.getRuntime(chain.id) throws for a chain whose runtime isn't ready yet
// (including a disabled chain, via DisabledChainException) - if that throw escapes this function
// uncaught, it propagates through the merge and kills governance/staking/crowdloan sync for every OTHER
// chain in the group too, not just the failing one. Wrapping the whole body in flow{} + catch isolates
// that failure to this chain alone.
protected suspend fun runUpdaters(chain: Chain, updaters: Collection<Updater<*>>): Flow<Updater.SideEffect> {
val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata
return flow {
val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata
val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) ->
scope.invalidationFlow().flatMapLatest { scopeValue ->
val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id)
val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) ->
scope.invalidationFlow().flatMapLatest { scopeValue ->
val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id)
val updatersFlow = scopeUpdaters
.filter { it.requiredModules.all(runtimeMetadata::hasModule) }
.map { updater ->
@Suppress("UNCHECKED_CAST")
(updater as Updater<Any?>).listenForUpdates(subscriptionBuilder, scopeValue)
.catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) }
.flowOn(Dispatchers.Default)
val updatersFlow = scopeUpdaters
.filter { it.requiredModules.all(runtimeMetadata::hasModule) }
.map { updater ->
@Suppress("UNCHECKED_CAST")
(updater as Updater<Any?>).listenForUpdates(subscriptionBuilder, scopeValue)
.catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) }
.flowOn(Dispatchers.Default)
}
if (updatersFlow.isNotEmpty()) {
subscriptionBuilder.subscribe(coroutineContext)
updatersFlow.merge()
} else {
emptyFlow()
}
if (updatersFlow.isNotEmpty()) {
subscriptionBuilder.subscribe(coroutineContext)
updatersFlow.merge()
} else {
emptyFlow()
}
}
}
return scopeFlows.merge()
emitAll(scopeFlows.merge())
}.catch { error ->
// Explicitly qualified: unqualified LOG_TAG here would resolve against the nearest implicit
// receiver, which is this catch lambda's FlowCollector, not this class - Any.LOG_TAG applies to
// any receiver, so it would silently compile but log the wrong (unhelpful) tag.
val outerLogTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG
val outerSelfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName
Log.e(outerLogTag, "Failed to start updaters in $outerSelfName for ${chain.name}", error)
}
}
}