Persist exchange rates, selected currencies, and price source via SQLDelight

Adds a SQLDelight-backed AppDatabase (Android, Desktop, iOS/macOS) so the
converter survives restarts: the last-known rates per price source, the
user's selected fiat currencies (with order preserved), and the last-used
price source all reload before the first network fetch completes. Web isn't
a shipped platform and SQLDelight's driver there needs a worker plus a wasm
sqlite binary, so it gets a lighter localStorage-backed implementation of
the same store interfaces instead.

Android needs an app Context for its driver, wired via a new
SatsPriceApplication. iOS/macOS need libsqlite3 linked explicitly (Swift's
autolinking only covers Shared.framework itself, not its C library
dependencies), plus two extension-loading symbols marked optional since
macOS's system sqlite build omits them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpjMKWGoiT5aJBzhxvXkwp
This commit is contained in:
2026-09-08 09:34:21 +03:00
co-authored by Claude Sonnet 5
parent 8a73b140c4
commit 14298d450e
20 changed files with 506 additions and 3 deletions
+17 -1
View File
@@ -8,6 +8,7 @@ plugins {
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.mokoResources)
alias(libs.plugins.sqldelight)
}
kotlin {
@@ -64,6 +65,7 @@ kotlin {
implementation(libs.compose.uiToolingPreview)
implementation(libs.compose.uiTooling)
implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.androidDriver)
}
commonMain.dependencies {
implementation(libs.compose.runtime)
@@ -82,6 +84,7 @@ kotlin {
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.serialization.kotlinxJson)
implementation(libs.bignum)
implementation(libs.sqldelight.runtime)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
@@ -90,9 +93,14 @@ kotlin {
}
appleMain.dependencies {
implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.nativeDriver)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
implementation(libs.sqldelight.sqliteDriver)
}
webMain.dependencies {
implementation(libs.kotlinx.browser)
}
jsMain.dependencies {
implementation(libs.wrappers.browser)
@@ -110,4 +118,12 @@ dependencies {
multiplatformResources {
resourcesPackage.set("xyz.tyiu.satsprice.shared")
}
}
sqldelight {
databases {
register("AppDatabase") {
packageName.set("xyz.tyiu.satsprice.db")
}
}
}
@@ -0,0 +1,11 @@
package xyz.tyiu.satsprice.data.db
import android.content.Context
internal lateinit var androidAppContext: Context
private set
/** Called once from [android.app.Application.onCreate] so the shared module can open its database. */
fun initAndroidContext(context: Context) {
androidAppContext = context.applicationContext
}
@@ -0,0 +1,12 @@
package xyz.tyiu.satsprice.data.db
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import xyz.tyiu.satsprice.db.AppDatabase
private val database: AppDatabase by lazy {
AppDatabase(AndroidSqliteDriver(AppDatabase.Schema, androidAppContext, "sats-price.db"))
}
actual fun createExchangeRateStore(): ExchangeRateStore = SqlDelightExchangeRateStore(database)
actual fun createSelectedCurrenciesStore(): SelectedCurrenciesStore = SqlDelightSelectedCurrenciesStore(database)
actual fun createSelectedSourceStore(): SelectedSourceStore = SqlDelightSelectedSourceStore(database)
@@ -0,0 +1,12 @@
package xyz.tyiu.satsprice.data.db
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import xyz.tyiu.satsprice.db.AppDatabase
private val database: AppDatabase by lazy {
AppDatabase(NativeSqliteDriver(AppDatabase.Schema, "sats-price.db"))
}
actual fun createExchangeRateStore(): ExchangeRateStore = SqlDelightExchangeRateStore(database)
actual fun createSelectedCurrenciesStore(): SelectedCurrenciesStore = SqlDelightSelectedCurrenciesStore(database)
actual fun createSelectedSourceStore(): SelectedSourceStore = SqlDelightSelectedSourceStore(database)
@@ -0,0 +1,11 @@
package xyz.tyiu.satsprice.data.db
import xyz.tyiu.satsprice.data.ExchangeRates
/** Persists the last known [ExchangeRates] fetched from each price source, keyed by [ExchangeRateSource.id]. */
interface ExchangeRateStore {
suspend fun loadLastKnownRates(sourceId: String): ExchangeRates?
suspend fun saveRates(sourceId: String, rates: ExchangeRates)
}
expect fun createExchangeRateStore(): ExchangeRateStore
@@ -0,0 +1,9 @@
package xyz.tyiu.satsprice.data.db
/** Persists the user's selected fiat currency codes, preserving the order they're displayed in. */
interface SelectedCurrenciesStore {
suspend fun loadSelectedCurrencies(): List<String>
suspend fun saveSelectedCurrencies(codes: List<String>)
}
expect fun createSelectedCurrenciesStore(): SelectedCurrenciesStore
@@ -0,0 +1,9 @@
package xyz.tyiu.satsprice.data.db
/** Persists the id of the last price source the user selected. */
interface SelectedSourceStore {
suspend fun loadSelectedSourceId(): String?
suspend fun saveSelectedSourceId(sourceId: String)
}
expect fun createSelectedSourceStore(): SelectedSourceStore
@@ -0,0 +1,68 @@
package xyz.tyiu.satsprice.data.db
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import xyz.tyiu.satsprice.data.ExchangeRates
import xyz.tyiu.satsprice.db.AppDatabase
import kotlin.time.Instant
/**
* Shared by every platform that opens a real [AppDatabase] (Android, Desktop, iOS/macOS) — only
* driver construction differs per platform, so that's the only expect/actual boundary needed.
* Web isn't a shipped platform (see README's Supported Platforms) and gets an in-memory fallback
* instead of a real driver.
*/
internal class SqlDelightExchangeRateStore(private val database: AppDatabase) : ExchangeRateStore {
override suspend fun loadLastKnownRates(sourceId: String): ExchangeRates? = withContext(Dispatchers.Default) {
val rows = database.exchangeRateQueries.selectForSource(sourceId).executeAsList()
val first = rows.firstOrNull() ?: return@withContext null
ExchangeRates(
base = first.base,
rates = rows.associate { it.currencyCode to BigDecimal.parseString(it.rate) },
fetchedAt = Instant.fromEpochMilliseconds(first.fetchedAt),
)
}
override suspend fun saveRates(sourceId: String, rates: ExchangeRates) = withContext(Dispatchers.Default) {
database.exchangeRateQueries.transaction {
database.exchangeRateQueries.deleteForSource(sourceId)
rates.rates.forEach { (currencyCode, rate) ->
database.exchangeRateQueries.insertRate(
sourceId = sourceId,
base = rates.base,
currencyCode = currencyCode,
rate = rate.toStringExpanded(),
fetchedAt = rates.fetchedAt.toEpochMilliseconds(),
)
}
}
}
}
internal class SqlDelightSelectedCurrenciesStore(private val database: AppDatabase) : SelectedCurrenciesStore {
override suspend fun loadSelectedCurrencies(): List<String> = withContext(Dispatchers.Default) {
database.selectedCurrencyQueries.selectAllOrdered().executeAsList().map { it.code }
}
override suspend fun saveSelectedCurrencies(codes: List<String>) = withContext(Dispatchers.Default) {
database.selectedCurrencyQueries.transaction {
database.selectedCurrencyQueries.deleteAll()
codes.forEachIndexed { index, code ->
database.selectedCurrencyQueries.insertSelected(code = code, position = index.toLong())
}
}
}
}
internal class SqlDelightSelectedSourceStore(private val database: AppDatabase) : SelectedSourceStore {
override suspend fun loadSelectedSourceId(): String? = withContext(Dispatchers.Default) {
database.selectedSourceQueries.select().executeAsOneOrNull()?.sourceId
}
override suspend fun saveSelectedSourceId(sourceId: String) = withContext(Dispatchers.Default) {
database.selectedSourceQueries.transaction {
database.selectedSourceQueries.upsert(sourceId)
}
}
}
@@ -19,6 +19,12 @@ import xyz.tyiu.satsprice.data.ExchangeRateSource
import xyz.tyiu.satsprice.data.ExchangeRates
import xyz.tyiu.satsprice.data.ManualExchangeRateSource
import xyz.tyiu.satsprice.data.createHttpClient
import xyz.tyiu.satsprice.data.db.ExchangeRateStore
import xyz.tyiu.satsprice.data.db.SelectedCurrenciesStore
import xyz.tyiu.satsprice.data.db.SelectedSourceStore
import xyz.tyiu.satsprice.data.db.createExchangeRateStore
import xyz.tyiu.satsprice.data.db.createSelectedCurrenciesStore
import xyz.tyiu.satsprice.data.db.createSelectedSourceStore
import xyz.tyiu.satsprice.domain.CurrencyConverter
import xyz.tyiu.satsprice.domain.formatAmount
import xyz.tyiu.satsprice.domain.formatAmountFixed
@@ -59,6 +65,9 @@ class PriceViewModel(
private val manualSource: ManualExchangeRateSource = ManualExchangeRateSource(),
private val coinbaseSource: ExchangeRateSource = CoinbaseExchangeRateSource(httpClient),
private val coinGeckoSource: ExchangeRateSource = CoinGeckoExchangeRateSource(httpClient),
private val exchangeRateStore: ExchangeRateStore = createExchangeRateStore(),
private val selectedCurrenciesStore: SelectedCurrenciesStore = createSelectedCurrenciesStore(),
private val selectedSourceStore: SelectedSourceStore = createSelectedSourceStore(),
) : ViewModel() {
private val sources: List<ExchangeRateSource> = listOf(coinbaseSource, coinGeckoSource, manualSource)
@@ -84,10 +93,25 @@ class PriceViewModel(
val uiState: StateFlow<ConverterUiState> = _uiState.asStateFlow()
private var rates: ExchangeRates? = null
private var lastPersistedSelection: List<String>? = null
init {
manualSource.currencyCode = defaultCurrencyCode
viewModelScope.launch {
selectedCurrenciesStore.loadSelectedCurrencies().takeIf { it.isNotEmpty() }?.let { persisted ->
lastPersistedSelection = persisted
val selection = if (defaultCurrencyCode in persisted) persisted else listOf(defaultCurrencyCode) + persisted
_uiState.update { it.copy(selectedFiatCurrencies = selection) }
}
selectedSourceStore.loadSelectedSourceId()
?.let { id -> sources.firstOrNull { it.id == id } }
?.let { persisted ->
currentSource = persisted
_uiState.update {
it.copy(sourceName = persisted.displayName, isManualSource = persisted === manualSource)
}
}
seedFromCache(currentSource)
while (isActive) {
refresh()
delay(AUTO_REFRESH_INTERVAL_MILLIS)
@@ -95,6 +119,19 @@ class PriceViewModel(
}
}
/** Shows the last known rates for [source] immediately, before its network fetch completes. */
private suspend fun seedFromCache(source: ExchangeRateSource) {
val cached = exchangeRateStore.loadLastKnownRates(source.id) ?: return
rates = cached
_uiState.update { state -> recomputeFromKnownField(state.copy(lastUpdated = cached.fetchedAt), cached) }
}
private fun persistSelectionIfChanged(selection: List<String>) {
if (selection == lastPersistedSelection) return
lastPersistedSelection = selection
viewModelScope.launch { selectedCurrenciesStore.saveSelectedCurrencies(selection) }
}
fun refresh() {
if (currentSource === manualSource && manualSource.rate == null) {
_uiState.update { it.copy(isLoading = false, errorMessage = null) }
@@ -105,6 +142,8 @@ class PriceViewModel(
try {
val newRates = currentSource.getRates("BTC")
rates = newRates
exchangeRateStore.saveRates(currentSource.id, newRates)
var selectionToPersist: List<String>? = null
_uiState.update { state ->
val available = systemCurrencyList
.filter { newRates.rates.containsKey(it.code) }
@@ -119,6 +158,7 @@ class PriceViewModel(
// whatever order the user picked via onFiatCurrenciesReordered.
val filtered = state.selectedFiatCurrencies.filter { it == defaultCurrencyCode || it in availableCodes }
val selection = if (defaultCurrencyCode in filtered) filtered else listOf(defaultCurrencyCode) + filtered
selectionToPersist = selection
recomputeFromKnownField(
state.copy(
isLoading = false,
@@ -130,6 +170,7 @@ class PriceViewModel(
newRates,
)
}
selectionToPersist?.let { persistSelectionIfChanged(it) }
} catch (e: Exception) {
// Not localized: this shared ViewModel has no platform Context to resolve a moko-resources
// string on Android, and no locale-aware synchronous resolution path that works everywhere.
@@ -157,6 +198,7 @@ class PriceViewModel(
val newState = state.copy(selectedFiatCurrencies = newSelection)
rates?.let { recomputeFromKnownField(newState, it) } ?: newState
}
persistSelectionIfChanged(_uiState.value.selectedFiatCurrencies)
}
/**
@@ -174,6 +216,7 @@ class PriceViewModel(
val newState = state.copy(selectedFiatCurrencies = newSelection)
rates?.let { recomputeFromKnownField(newState, it) } ?: newState
}
persistSelectionIfChanged(_uiState.value.selectedFiatCurrencies)
}
fun onSourceSelected(displayName: String) {
@@ -187,7 +230,11 @@ class PriceViewModel(
rateDisplays = emptyMap(),
)
}
refresh()
viewModelScope.launch {
selectedSourceStore.saveSelectedSourceId(selected.id)
seedFromCache(selected)
refresh()
}
}
fun onManualRateChanged(value: String) {
@@ -0,0 +1,18 @@
CREATE TABLE exchangeRateEntity (
sourceId TEXT NOT NULL,
base TEXT NOT NULL,
currencyCode TEXT NOT NULL,
rate TEXT NOT NULL,
fetchedAt INTEGER NOT NULL,
PRIMARY KEY (sourceId, currencyCode)
);
selectForSource:
SELECT * FROM exchangeRateEntity WHERE sourceId = ?;
deleteForSource:
DELETE FROM exchangeRateEntity WHERE sourceId = ?;
insertRate:
INSERT OR REPLACE INTO exchangeRateEntity (sourceId, base, currencyCode, rate, fetchedAt)
VALUES (?, ?, ?, ?, ?);
@@ -0,0 +1,14 @@
CREATE TABLE selectedCurrencyEntity (
code TEXT NOT NULL PRIMARY KEY,
position INTEGER NOT NULL
);
selectAllOrdered:
SELECT * FROM selectedCurrencyEntity ORDER BY position ASC;
deleteAll:
DELETE FROM selectedCurrencyEntity;
insertSelected:
INSERT INTO selectedCurrencyEntity (code, position)
VALUES (?, ?);
@@ -0,0 +1,11 @@
CREATE TABLE selectedSourceEntity (
id INTEGER NOT NULL PRIMARY KEY,
sourceId TEXT NOT NULL
);
select:
SELECT * FROM selectedSourceEntity WHERE id = 0;
upsert:
INSERT OR REPLACE INTO selectedSourceEntity (id, sourceId)
VALUES (0, ?);
@@ -0,0 +1,20 @@
package xyz.tyiu.satsprice.data.db
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import xyz.tyiu.satsprice.db.AppDatabase
import java.io.File
private val database: AppDatabase by lazy {
val appDir = File(System.getProperty("user.home"), ".sats-price").apply { mkdirs() }
val databaseFile = File(appDir, "sats-price.db")
val isNewDatabase = !databaseFile.exists()
val driver = JdbcSqliteDriver("jdbc:sqlite:${databaseFile.absolutePath}")
if (isNewDatabase) {
AppDatabase.Schema.create(driver)
}
AppDatabase(driver)
}
actual fun createExchangeRateStore(): ExchangeRateStore = SqlDelightExchangeRateStore(database)
actual fun createSelectedCurrenciesStore(): SelectedCurrenciesStore = SqlDelightSelectedCurrenciesStore(database)
actual fun createSelectedSourceStore(): SelectedSourceStore = SqlDelightSelectedSourceStore(database)
@@ -0,0 +1,74 @@
package xyz.tyiu.satsprice.data.db
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlinx.coroutines.test.runTest
import xyz.tyiu.satsprice.data.ExchangeRates
import xyz.tyiu.satsprice.db.AppDatabase
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.time.Instant
class SqlDelightStoresTest {
private fun newDatabase(): AppDatabase {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
AppDatabase.Schema.create(driver)
return AppDatabase(driver)
}
@Test
fun exchangeRateStoreRoundTripsRatesPerSource() = runTest {
val store = SqlDelightExchangeRateStore(newDatabase())
val rates = ExchangeRates(
base = "BTC",
rates = mapOf("USD" to BigDecimal.parseString("65000.12"), "EUR" to BigDecimal.parseString("60000.5")),
fetchedAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
)
assertNull(store.loadLastKnownRates("coinbase"))
store.saveRates("coinbase", rates)
val loaded = store.loadLastKnownRates("coinbase")
assertEquals(rates.base, loaded?.base)
assertEquals(rates.rates, loaded?.rates)
assertEquals(rates.fetchedAt, loaded?.fetchedAt)
// A different source's cache stays independent.
assertNull(store.loadLastKnownRates("coingecko"))
// Saving again for the same source replaces the prior rates entirely.
val updated = rates.copy(rates = mapOf("USD" to BigDecimal.parseString("70000")))
store.saveRates("coinbase", updated)
assertEquals(updated.rates, store.loadLastKnownRates("coinbase")?.rates)
}
@Test
fun selectedCurrenciesStoreRoundTripsOrder() = runTest {
val store = SqlDelightSelectedCurrenciesStore(newDatabase())
assertEquals(emptyList(), store.loadSelectedCurrencies())
store.saveSelectedCurrencies(listOf("USD", "EUR", "JPY"))
assertEquals(listOf("USD", "EUR", "JPY"), store.loadSelectedCurrencies())
// Saving replaces the whole set, including order.
store.saveSelectedCurrencies(listOf("JPY", "USD"))
assertEquals(listOf("JPY", "USD"), store.loadSelectedCurrencies())
}
@Test
fun selectedSourceStoreRoundTripsLastUsedSource() = runTest {
val store = SqlDelightSelectedSourceStore(newDatabase())
assertNull(store.loadSelectedSourceId())
store.saveSelectedSourceId("coinbase")
assertEquals("coinbase", store.loadSelectedSourceId())
// Saving again replaces the previously selected source.
store.saveSelectedSourceId("manual")
assertEquals("manual", store.loadSelectedSourceId())
}
}
@@ -0,0 +1,83 @@
package xyz.tyiu.satsprice.data.db
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlinx.browser.localStorage
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.tyiu.satsprice.data.ExchangeRates
import kotlin.time.Instant
/**
* Web isn't a shipped platform (see README's Supported Platforms) and SQLDelight's web driver
* needs a worker plus a wasm sqlite binary, so persistence here goes through the browser's
* localStorage instead of a real database — plenty for this small amount of data, and it
* survives page reloads unlike the in-memory state it replaced.
*/
private val json = Json { ignoreUnknownKeys = true }
@Serializable
private data class StoredExchangeRates(val base: String, val rates: Map<String, String>, val fetchedAtMillis: Long)
private class LocalStorageExchangeRateStore : ExchangeRateStore {
override suspend fun loadLastKnownRates(sourceId: String): ExchangeRates? {
val raw = localStorage.getItem(key(sourceId)) ?: return null
val stored = try {
json.decodeFromString<StoredExchangeRates>(raw)
} catch (e: Exception) {
return null
}
return ExchangeRates(
base = stored.base,
rates = stored.rates.mapValues { (_, value) -> BigDecimal.parseString(value) },
fetchedAt = Instant.fromEpochMilliseconds(stored.fetchedAtMillis),
)
}
override suspend fun saveRates(sourceId: String, rates: ExchangeRates) {
val stored = StoredExchangeRates(
base = rates.base,
rates = rates.rates.mapValues { (_, value) -> value.toStringExpanded() },
fetchedAtMillis = rates.fetchedAt.toEpochMilliseconds(),
)
localStorage.setItem(key(sourceId), json.encodeToString(stored))
}
private fun key(sourceId: String) = "satsprice.exchangeRate.$sourceId"
}
private class LocalStorageSelectedCurrenciesStore : SelectedCurrenciesStore {
override suspend fun loadSelectedCurrencies(): List<String> {
val raw = localStorage.getItem(KEY) ?: return emptyList()
return try {
json.decodeFromString<List<String>>(raw)
} catch (e: Exception) {
emptyList()
}
}
override suspend fun saveSelectedCurrencies(codes: List<String>) {
localStorage.setItem(KEY, json.encodeToString(codes))
}
private companion object {
const val KEY = "satsprice.selectedCurrencies"
}
}
private class LocalStorageSelectedSourceStore : SelectedSourceStore {
override suspend fun loadSelectedSourceId(): String? = localStorage.getItem(KEY)
override suspend fun saveSelectedSourceId(sourceId: String) {
localStorage.setItem(KEY, sourceId)
}
private companion object {
const val KEY = "satsprice.selectedSourceId"
}
}
actual fun createExchangeRateStore(): ExchangeRateStore = LocalStorageExchangeRateStore()
actual fun createSelectedCurrenciesStore(): SelectedCurrenciesStore = LocalStorageSelectedCurrenciesStore()
actual fun createSelectedSourceStore(): SelectedSourceStore = LocalStorageSelectedSourceStore()
@@ -0,0 +1,56 @@
package xyz.tyiu.satsprice.data.db
import com.ionspin.kotlin.bignum.decimal.BigDecimal
import kotlinx.browser.localStorage
import kotlinx.coroutines.test.runTest
import xyz.tyiu.satsprice.data.ExchangeRates
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.time.Instant
/**
* A fresh store instance stands in for a page reload: since these stores are backed by
* localStorage rather than an in-memory field, one instance's writes must be visible to another.
*/
class LocalStorageStoresTest {
@Test
fun exchangeRateStorePersistsAcrossInstances() = runTest {
localStorage.clear()
assertNull(createExchangeRateStore().loadLastKnownRates("coinbase"))
val rates = ExchangeRates(
base = "BTC",
rates = mapOf("USD" to BigDecimal.parseString("65000.12"), "EUR" to BigDecimal.parseString("60000.5")),
fetchedAt = Instant.fromEpochMilliseconds(1_700_000_000_000),
)
createExchangeRateStore().saveRates("coinbase", rates)
val reloaded = createExchangeRateStore().loadLastKnownRates("coinbase")
assertEquals(rates.base, reloaded?.base)
assertEquals(rates.rates, reloaded?.rates)
assertEquals(rates.fetchedAt, reloaded?.fetchedAt)
// A different source's cache stays independent.
assertNull(createExchangeRateStore().loadLastKnownRates("coingecko"))
}
@Test
fun selectedCurrenciesStorePersistsAcrossInstances() = runTest {
localStorage.clear()
assertEquals(emptyList(), createSelectedCurrenciesStore().loadSelectedCurrencies())
createSelectedCurrenciesStore().saveSelectedCurrencies(listOf("USD", "EUR", "JPY"))
assertEquals(listOf("USD", "EUR", "JPY"), createSelectedCurrenciesStore().loadSelectedCurrencies())
}
@Test
fun selectedSourceStorePersistsAcrossInstances() = runTest {
localStorage.clear()
assertNull(createSelectedSourceStore().loadSelectedSourceId())
createSelectedSourceStore().saveSelectedSourceId("coingecko")
assertEquals("coingecko", createSelectedSourceStore().loadSelectedSourceId())
}
}