feat: migrate nodeAliasCache to room

This commit is contained in:
2026-06-01 23:59:42 +02:00
parent 38c6c6f723
commit 93332dc683
12 changed files with 166 additions and 180 deletions
@@ -7,10 +7,10 @@ import java.util.concurrent.TimeUnit
/** /**
* Resolves a Lightning node pubkey to a human-readable alias. * Resolves a Lightning node pubkey to a human-readable alias.
* Tries 1ml.com first, falls back to mempool.space. * Tries mempool.space first, falls back to 1ml.com.
* Returns null if neither source has the node or both fail. * Returns null if neither source has the node or both fail.
*/ */
class NodeAliasService { class NodeAliasClient {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS) .connectTimeout(5, TimeUnit.SECONDS)
@@ -34,15 +34,15 @@ class NodeAliasService {
suspend fun resolveAlias(pubkey: String): String? { suspend fun resolveAlias(pubkey: String): String? {
if (pubkey.isBlank()) return null if (pubkey.isBlank()) return null
// 1. Try 1ml.com // 1. Try mempool.space
runCatching { oneMlApi.getNode(pubkey) } runCatching { mempoolApi.getNode(pubkey) }
.onSuccess { response -> .onSuccess { response ->
val alias = response.alias?.takeIf { it.isNotBlank() } val alias = response.alias?.takeIf { it.isNotBlank() }
if (alias != null) return alias if (alias != null) return alias
} }
// 2. Fall back to mempool.space // 2. Fall back to 1ml.com
runCatching { mempoolApi.getNode(pubkey) } runCatching { oneMlApi.getNode(pubkey) }
.onSuccess { response -> .onSuccess { response ->
val alias = response.alias?.takeIf { it.isNotBlank() } val alias = response.alias?.takeIf { it.isNotBlank() }
if (alias != null) return alias if (alias != null) return alias
@@ -10,10 +10,10 @@ import kotlinx.coroutines.flow.StateFlow
class LNbitsNodeAliasRepository( class LNbitsNodeAliasRepository(
private val service: NodeAliasService private val service: NodeAliasService
) : NodeAliasRepository { ) : NodeAliasRepository {
override val aliases: StateFlow<Map<String, String>> override val aliases: StateFlow<Map<String, String>>
get() = service.aliases get() = service.aliases
override suspend fun resolve(pubkey: String): String? = override suspend fun resolve(pubkey: String): String? =
service.resolve(pubkey) service.resolve(pubkey)
override suspend fun warmUp() = service.warmUp()
} }
@@ -1,10 +1,8 @@
package com.bitcointxoko.gudariwallet.data package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import com.bitcointxoko.gudariwallet.api.* import com.bitcointxoko.gudariwallet.api.*
import com.bitcointxoko.gudariwallet.api.LNbitsClient.httpClient
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.LnurlBech32 import com.bitcointxoko.gudariwallet.util.LnurlBech32
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
@@ -15,9 +13,6 @@ private const val TAG = "LNbitsWalletRepo"
class LNbitsWalletRepository( class LNbitsWalletRepository(
private val api: LNbitsApi, private val api: LNbitsApi,
private val secrets: SecretStore, private val secrets: SecretStore,
private val context : Context,
private val nodeAliasService: com.bitcointxoko.gudariwallet.service.NodeAliasService =
com.bitcointxoko.gudariwallet.service.NodeAliasService(httpClient, context),
) : WalletRepository { ) : WalletRepository {
// ── Balance ─────────────────────────────────────────────────────────────── // ── Balance ───────────────────────────────────────────────────────────────
@@ -157,7 +152,7 @@ class LNbitsWalletRepository(
append(amountMsat) append(amountMsat)
if (!comment.isNullOrBlank()) { if (!comment.isNullOrBlank()) {
append("&comment=") append("&comment=")
append(android.net.Uri.encode(comment)) append(Uri.encode(comment))
} }
} }
Log.d(TAG, "LNURL [FETCH INVOICE] callback=$callbackUrl") Log.d(TAG, "LNURL [FETCH INVOICE] callback=$callbackUrl")
@@ -0,0 +1,68 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.util.Log
import com.bitcointxoko.gudariwallet.data.db.AppDatabase
import com.bitcointxoko.gudariwallet.data.db.NodeAliasCacheEntity
import com.bitcointxoko.gudariwallet.util.WalletConstants
private const val TAG = "NodeAliasCacheRepository"
class NodeAliasCacheRepository(context: Context) {
private val dao = AppDatabase.getInstance(context).nodeAliasCacheDao()
/**
* Returns a cached alias for [pubkey] if it exists and is within
* [WalletConstants.ALIAS_CACHE_TTL_MS]. Returns null on miss or stale.
*/
suspend fun get(pubkey: String): String? {
val entry = dao.getByPubkey(pubkey) ?: run {
Log.d(TAG, "ALIAS [CACHE MISS ] $pubkey")
return null
}
val ageMs = System.currentTimeMillis() - entry.savedAt
return if (ageMs < WalletConstants.ALIAS_CACHE_TTL_MS) {
Log.d(TAG, "ALIAS [CACHE HIT ] $pubkey\"${entry.alias}\" (age ${ageMs / 1000}s)")
entry.alias
} else {
Log.d(TAG, "ALIAS [CACHE STALE] $pubkey (age ${ageMs / 1000}s) — will refetch")
null
}
}
/** Upsert an alias into the Room cache. */
suspend fun put(pubkey: String, alias: String) {
dao.insert(
NodeAliasCacheEntity(
pubkey = pubkey,
alias = alias,
savedAt = System.currentTimeMillis()
)
)
Log.d(TAG, "ALIAS [PERSIST ] $pubkey\"$alias\"")
}
/**
* Load all non-stale entries from Room as a plain map.
* Used to seed the StateFlow on startup — replaces [loadPersistedCache].
*/
suspend fun loadAll(): Map<String, String> {
val now = System.currentTimeMillis()
return dao.getAll()
.filter { now - it.savedAt < WalletConstants.ALIAS_PERSIST_TTL_MS }
.associate { it.pubkey to it.alias }
.also { Log.d(TAG, "ALIAS [PERSIST ] Loaded ${it.size} non-stale entries from Room") }
}
/** Remove a single entry. */
suspend fun remove(pubkey: String) {
dao.deleteByPubkey(pubkey)
}
/** Wipe the entire alias cache — mirrors [clearPersistedCache]. */
suspend fun clearAll() {
dao.deleteAll()
Log.d(TAG, "ALIAS [PERSIST ] Cache cleared")
}
}
@@ -18,4 +18,5 @@ interface NodeAliasRepository {
* Results are cached — repeated calls for the same pubkey are cheap. * Results are cached — repeated calls for the same pubkey are cheap.
*/ */
suspend fun resolve(pubkey: String): String? suspend fun resolve(pubkey: String): String?
suspend fun warmUp()
} }
@@ -9,8 +9,12 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
@Database( @Database(
entities = [PaymentRecordEntity::class, LnurlCacheEntity::class], entities = [
version = 2, PaymentRecordEntity::class,
LnurlCacheEntity::class,
NodeAliasCacheEntity::class, // ← added
],
version = 3, // ← bumped
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
@@ -18,6 +22,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao abstract fun paymentDao(): PaymentDao
abstract fun lnurlCacheDao(): LnurlCacheDao abstract fun lnurlCacheDao(): LnurlCacheDao
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao // ← added
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @Volatile private var INSTANCE: AppDatabase? = null
@@ -37,6 +42,21 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
private val MIGRATION_2_3 = object : Migration(2, 3) { // ← added
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `node_alias_cache` (
`pubkey` TEXT NOT NULL,
`alias` TEXT NOT NULL,
`savedAt` INTEGER NOT NULL,
PRIMARY KEY(`pubkey`)
)
""".trimIndent()
)
}
}
fun getInstance(context: Context): AppDatabase = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -44,7 +64,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"gudari_wallet.db" "gudari_wallet.db"
) )
.addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_1_2, MIGRATION_2_3) // ← added
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
} }
@@ -0,0 +1,25 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface NodeAliasCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entry: NodeAliasCacheEntity)
@Query("SELECT * FROM node_alias_cache WHERE pubkey = :pubkey LIMIT 1")
suspend fun getByPubkey(pubkey: String): NodeAliasCacheEntity?
@Query("SELECT * FROM node_alias_cache")
suspend fun getAll(): List<NodeAliasCacheEntity>
@Query("DELETE FROM node_alias_cache WHERE pubkey = :pubkey")
suspend fun deleteByPubkey(pubkey: String)
@Query("DELETE FROM node_alias_cache")
suspend fun deleteAll()
}
@@ -0,0 +1,12 @@
package com.bitcointxoko.gudariwallet.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "node_alias_cache")
data class NodeAliasCacheEntity(
@PrimaryKey
val pubkey: String, // node public key
val alias: String, // resolved human-readable alias
val savedAt: Long // epoch ms — used for TTL checks
)
@@ -1,184 +1,54 @@
package com.bitcointxoko.gudariwallet.service package com.bitcointxoko.gudariwallet.service
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.api.NodeAliasClient as NodeAliasApiClient
import com.google.gson.Gson import com.bitcointxoko.gudariwallet.data.NodeAliasCacheRepository
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
private const val TAG = "NodeAliasService" private const val TAG = "NodeAliasService"
class NodeAliasService( class NodeAliasService(
private val httpClient : OkHttpClient, private val apiClient: NodeAliasApiClient, // ← injected, not duplicated
private val context : Context context: Context
) { ) {
private val repo = NodeAliasCacheRepository(context)
// ── Persistence ───────────────────────────────────────────────────────────
private val prefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.ALIAS_PREFS_NAME, Context.MODE_PRIVATE
)
private val gson = Gson()
// ── In-memory alias cache ─────────────────────────────────────────────────
private data class CachedAlias(val alias: String, val fetchedAt: Long)
// Type token for Gson deserialisation
private val cacheType = object : TypeToken<Map<String, CachedAlias>>() {}.type
private val cache = ConcurrentHashMap<String, CachedAlias>()
// ── Shared alias StateFlow — all screens collect this ─────────────────────
private val _aliases = MutableStateFlow<Map<String, String>>(emptyMap()) private val _aliases = MutableStateFlow<Map<String, String>>(emptyMap())
val aliases: StateFlow<Map<String, String>> = _aliases.asStateFlow() val aliases: StateFlow<Map<String, String>> = _aliases.asStateFlow()
// ── Init — load persisted cache from SharedPreferences ──────────────────── suspend fun warmUp() {
_aliases.value = repo.loadAll()
init {
loadPersistedCache()
} }
private fun loadPersistedCache() {
val json = prefs.getString(WalletConstants.ALIAS_PREFS_KEY, null) ?: run {
Log.d(TAG, "ALIAS [PERSIST ] No persisted cache found — starting fresh")
return
}
runCatching {
val persisted: Map<String, CachedAlias> = gson.fromJson(json, cacheType)
val now = System.currentTimeMillis()
var loaded = 0
var dropped = 0
persisted.forEach { (pubkey, entry) ->
val ageMs = now - entry.fetchedAt
if (ageMs < WalletConstants.ALIAS_PERSIST_TTL_MS) {
cache[pubkey] = entry
loaded++
} else {
dropped++
Log.d(TAG, "ALIAS [PERSIST ] Dropped stale entry for $pubkey (age ${ageMs / 3_600_000}h)")
}
}
// Seed the StateFlow with everything we loaded
_aliases.value = cache.mapValues { it.value.alias }
Log.d(TAG, "ALIAS [PERSIST ] Loaded $loaded entries, dropped $dropped stale entries")
}.onFailure { e ->
Log.w(TAG, "ALIAS [PERSIST ] Failed to deserialise cache — starting fresh: ${e.message}")
}
}
private fun persistCache() {
runCatching {
val json = gson.toJson(cache)
prefs.edit().putString(WalletConstants.ALIAS_PREFS_KEY, json).apply()
Log.d(TAG, "ALIAS [PERSIST ] Wrote ${cache.size} entries to SharedPreferences")
}.onFailure { e ->
Log.w(TAG, "ALIAS [PERSIST ] Failed to write cache: ${e.message}")
}
}
// ── Public API ────────────────────────────────────────────────────────────
suspend fun resolve(pubkey: String): String? { suspend fun resolve(pubkey: String): String? {
val now = System.currentTimeMillis() // 1. Room cache hit
repo.get(pubkey)?.let {
cache[pubkey]?.let { entry -> Log.d(TAG, "ALIAS [CACHE HIT ] $pubkey\"$it\"")
if (now - entry.fetchedAt < WalletConstants.ALIAS_CACHE_TTL_MS) { return it
Log.d(TAG, "ALIAS [CACHE HIT ] $pubkey\"${entry.alias}\" (age ${(now - entry.fetchedAt) / 1000}s)")
return entry.alias
} else {
Log.d(TAG, "ALIAS [CACHE STALE] $pubkey (age ${(now - entry.fetchedAt) / 1000}s) — refetching")
} }
} ?: Log.d(TAG, "ALIAS [CACHE MISS ] $pubkey — fetching from network")
val alias = fetchFromNetwork(pubkey) // 2. Network via api/NodeAliasService (Retrofit, typed responses)
Log.d(TAG, "ALIAS [CACHE MISS] $pubkey — fetching from network")
if (alias == null) { val alias = apiClient.resolveAlias(pubkey) ?: run {
Log.w(TAG, "ALIAS [NOT FOUND ] $pubkey — no alias from mempool or 1ml") Log.w(TAG, "ALIAS [NOT FOUND ] $pubkey")
return null return null
} }
// Update in-memory cache // 3. Persist to Room
cache[pubkey] = CachedAlias(alias = alias, fetchedAt = System.currentTimeMillis()) repo.put(pubkey, alias)
// Publish to shared StateFlow // 4. Publish to StateFlow
_aliases.value = _aliases.value + (pubkey to alias) _aliases.value = _aliases.value + (pubkey to alias)
// Persist to SharedPreferences Log.d(TAG, "ALIAS [FETCHED ] $pubkey\"$alias\"")
persistCache()
Log.d(TAG, "ALIAS [API FETCHED] $pubkey\"$alias\"")
return alias return alias
} }
/** Clears in-memory cache, StateFlow, and persisted SharedPreferences entry. */ suspend fun clearPersistedCache() {
fun clearPersistedCache() { repo.clearAll()
cache.clear()
_aliases.value = emptyMap() _aliases.value = emptyMap()
prefs.edit().remove(WalletConstants.ALIAS_PREFS_KEY).apply()
Log.d(TAG, "ALIAS [PERSIST ] Cache cleared")
}
// ── Private helpers ───────────────────────────────────────────────────────
private suspend fun fetchFromNetwork(pubkey: String): String? = withContext(Dispatchers.IO) {
Log.d(TAG, "ALIAS [TRYING ] mempool.space for $pubkey")
val mempoolResult = fetchFromMempool(pubkey)
if (mempoolResult != null) {
Log.d(TAG, "ALIAS [SOURCE ] mempool.space resolved $pubkey\"$mempoolResult\"")
return@withContext mempoolResult
}
Log.d(TAG, "ALIAS [TRYING ] 1ml.com fallback for $pubkey")
val mlResult = fetchFrom1ml(pubkey)
if (mlResult != null) {
Log.d(TAG, "ALIAS [SOURCE ] 1ml.com resolved $pubkey\"$mlResult\"")
}
mlResult
}
private fun fetchFromMempool(pubkey: String): String? {
return try {
val request = Request.Builder()
.url("https://mempool.space/api/v1/lightning/nodes/$pubkey")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}
} catch (e: Exception) {
Log.w(TAG, "mempool.space lookup failed for $pubkey: ${e.message}")
null
}
}
private fun fetchFrom1ml(pubkey: String): String? {
return try {
val request = Request.Builder()
.url("https://1ml.com/node/$pubkey/json")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.string() ?: return null
Regex(""""alias"\s*:\s*"([^"]+)"""").find(body)?.groupValues?.get(1)
}
} catch (e: Exception) {
Log.w(TAG, "1ml.com lookup failed for $pubkey: ${e.message}")
null
}
} }
} }
@@ -67,6 +67,7 @@ class WalletViewModel(
init { init {
observePaymentEvents() observePaymentEvents()
fetchLightningAddress() fetchLightningAddress()
viewModelScope.launch { aliasRepo.warmUp() }
} }
// ── WebSocket event collection ──────────────────────────────────────────── // ── WebSocket event collection ────────────────────────────────────────────
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.bitcointxoko.gudariwallet.api.LNbitsClient import com.bitcointxoko.gudariwallet.api.LNbitsClient
import com.bitcointxoko.gudariwallet.api.NodeAliasClient
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
@@ -24,12 +25,12 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val secrets = EncryptedSecretStore(app) val secrets = EncryptedSecretStore(app)
val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" } val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" }
val api = LNbitsClient.create(baseUrl) val api = LNbitsClient.create(baseUrl)
val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app) val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
val lnurlCache = LnurlCacheRepository(app) val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc) val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app) val repo = LNbitsWalletRepository(api = api, secrets = secrets)
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
val balanceVm = BalanceViewModel(repo, balancePrefs) val balanceVm = BalanceViewModel(repo, balancePrefs)
val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm) val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm)
@@ -5,10 +5,6 @@ object WalletConstants {
// ── Unit conversion ─────────────────────────────────────────────────────── // ── Unit conversion ───────────────────────────────────────────────────────
const val MSAT_PER_SAT = 1_000L // millisatoshis per satoshi const val MSAT_PER_SAT = 1_000L // millisatoshis per satoshi
// ── Polling ───────────────────────────────────────────────────────────────
const val POLL_INTERVAL_MS = 3_000L // delay between payment status checks
const val POLL_MAX_ATTEMPTS = 200 // 200 × 3s = 10 minutes max
// ── WebSocket reconnect ─────────────────────────────────────────────────── // ── WebSocket reconnect ───────────────────────────────────────────────────
const val WS_INITIAL_BACKOFF_MS = 1_000L // first retry delay const val WS_INITIAL_BACKOFF_MS = 1_000L // first retry delay
const val WS_MAX_BACKOFF_MS = 16_000L // cap — never wait longer than this const val WS_MAX_BACKOFF_MS = 16_000L // cap — never wait longer than this
@@ -33,9 +29,7 @@ object WalletConstants {
const val PAYMENTS_PAGE_SIZE = 20 const val PAYMENTS_PAGE_SIZE = 20
// ── Alias cache ─────────────────────────────────────────────────────────── // ── Alias cache ───────────────────────────────────────────────────────────
const val ALIAS_CACHE_TTL_MS = 3_600_000L // 1 hour — in-memory stale threshold const val ALIAS_CACHE_TTL_MS = 3_600_000L // 1 hour — in-memory stale threshold
const val ALIAS_PREFS_NAME = "alias_cache_prefs" const val ALIAS_PERSIST_TTL_MS = 21 * 24 * 60 * 60 * 1000L // 21 days — drop on load if older than this
const val ALIAS_PREFS_KEY = "alias_cache"
const val ALIAS_PERSIST_TTL_MS = 86_400_000L // 24 hours — drop on load if older than this
// ── LNURL metadata cache ────────────────────────────────────────────────── // ── LNURL metadata cache ──────────────────────────────────────────────────
const val LNURL_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days const val LNURL_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000L // 7 days
// ── Payments cache ──────────────────────────────────────────────────────── // ── Payments cache ────────────────────────────────────────────────────────
@@ -48,5 +42,4 @@ object WalletConstants {
const val BALANCE_PREFS_KEY = "last_balance_sats" const val BALANCE_PREFS_KEY = "last_balance_sats"
const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at" const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at"
const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour
} }