Skip to content

Commit 246a169

Browse files
committed
fix(exchange): unify rate source to eliminate display/verified rate drift
OpenCodeExchange maintained a separate RatesBox cache that could drift from the verified protos in VerifiedProtoManager, causing server-side "native amount and quark value mismatch" and "exchange rate is stale" errors on intent submission. VerifiedProtoManager is now the single source of truth for exchange rates. OpenCodeExchange delegates all rate lookups to it, removing the redundant RatesBox. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent f2bbeb2 commit 246a169

5 files changed

Lines changed: 161 additions & 74 deletions

File tree

services/opencode/src/main/kotlin/com/getcode/opencode/ExchangeFactory.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.getcode.opencode
33
import android.content.Context
44
import com.getcode.opencode.exchange.Exchange
55
import com.getcode.opencode.inject.OpenCodeModule
6+
import com.getcode.opencode.internal.manager.VerifiedProtoManager
67
import com.getcode.util.locale.LocaleModule
78
import com.getcode.util.resources.AndroidResources
89
import dagger.hilt.android.EntryPointAccessors
@@ -26,8 +27,10 @@ object ExchangeFactory {
2627
val locale = localeModule.bindLocaleHelper(context)
2728
val resources = AndroidResources(context)
2829
val controller = ControllerFactory.createCurrencyController(context, config)
30+
val verifiedStateManager = VerifiedProtoManager()
2931
return module.providesExchange(
3032
currencyController = controller,
33+
verifiedStateManager = verifiedStateManager,
3134
resources = resources,
3235
locale = locale,
3336
)

services/opencode/src/main/kotlin/com/getcode/opencode/inject/OpenCodeModule.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,12 @@ object OpenCodeModule {
6161
@Singleton
6262
internal fun providesExchange(
6363
currencyController: CurrencyController,
64+
verifiedStateManager: VerifiedProtoManager,
6465
resources: ResourceHelper,
6566
locale: LocaleHelper,
6667
): Exchange = OpenCodeExchange(
6768
currencyController = currencyController,
69+
verifiedStateManager = verifiedStateManager,
6870
resources = resources,
6971
locale = locale,
7072
)

services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/OpenCodeExchange.kt

Lines changed: 29 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@ import androidx.lifecycle.ProcessLifecycleOwner
66
import com.getcode.opencode.controllers.CurrencyController
77
import com.getcode.opencode.exchange.Exchange
88
import com.getcode.opencode.internal.extensions.fromCode
9-
import com.getcode.opencode.model.financial.LiveMintDataResponse
9+
import com.getcode.opencode.internal.manager.VerifiedProtoManager
1010
import com.getcode.opencode.model.financial.Currency
1111
import com.getcode.opencode.model.financial.CurrencyCode
1212
import com.getcode.opencode.model.financial.Rate
1313
import com.getcode.solana.keys.Mint
14-
import com.getcode.util.format
1514
import com.getcode.util.locale.LocaleHelper
1615
import com.getcode.util.resources.ResourceHelper
1716
import com.getcode.util.resources.ResourceType
@@ -23,21 +22,19 @@ import kotlinx.coroutines.Job
2322
import kotlinx.coroutines.SupervisorJob
2423
import kotlinx.coroutines.flow.Flow
2524
import kotlinx.coroutines.flow.MutableStateFlow
26-
import kotlinx.coroutines.flow.filterIsInstance
25+
import kotlinx.coroutines.flow.distinctUntilChanged
2726
import kotlinx.coroutines.launch
2827
import kotlinx.coroutines.withContext
29-
import kotlinx.datetime.Instant
30-
import java.util.Date
3128
import javax.inject.Inject
32-
import kotlin.time.Clock
33-
import kotlin.time.Duration.Companion.minutes
3429

3530
internal class OpenCodeExchange @Inject constructor(
3631
private val currencyController: CurrencyController,
32+
private val verifiedStateManager: VerifiedProtoManager,
3733
private val resources: ResourceHelper,
3834
private val locale: LocaleHelper,
3935
) : Exchange, DefaultLifecycleObserver {
4036

37+
private var ratesCollectionJob: Job? = null
4138
private var exchangeRatesStream: Job? = null
4239

4340
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -70,7 +67,7 @@ internal class OpenCodeExchange @Inject constructor(
7067

7168
override suspend fun setPreferredBalanceCurrency(currencyCode: CurrencyCode) {
7269
balanceCurrency = currencyCode
73-
rates.rateFor(currencyCode)?.let {
70+
verifiedStateManager.rateFor(currencyCode)?.let {
7471
_balanceRate.value = it
7572
} ?: run {
7673
_balanceRate.value = Rate.oneToOne.copy(currency = currencyCode)
@@ -89,7 +86,7 @@ internal class OpenCodeExchange @Inject constructor(
8986

9087
override suspend fun setPreferredEntryCurrency(currencyCode: CurrencyCode) {
9188
entryCurrency = currencyCode
92-
rates.rateFor(currencyCode)?.let {
89+
verifiedStateManager.rateFor(currencyCode)?.let {
9390
_entryRate.value = it
9491
} ?: run {
9592
_entryRate.value = Rate.oneToOne.copy(currency = currencyCode)
@@ -103,15 +100,8 @@ internal class OpenCodeExchange @Inject constructor(
103100
private var balanceCurrency: CurrencyCode? = null
104101
private var entryCurrency: CurrencyCode? = null
105102

106-
private val _rates = MutableStateFlow(emptyMap<CurrencyCode, Rate>())
107-
private var rates = RatesBox(0, emptyMap())
108-
set(value) {
109-
field = value
110-
_rates.value = value.rates
111-
}
112-
113-
override fun rates() = rates.rates
114-
override fun observeRates(): Flow<Map<CurrencyCode, Rate>> = _rates
103+
override fun rates() = verifiedStateManager.rates()
104+
override fun observeRates(): Flow<Map<CurrencyCode, Rate>> = verifiedStateManager.observeRates()
115105

116106
override suspend fun getCurrenciesWithRates(rates: Map<CurrencyCode, Rate>): List<Currency> =
117107
withContext(Dispatchers.Default) {
@@ -149,14 +139,15 @@ internal class OpenCodeExchange @Inject constructor(
149139

150140
private fun stopStreamingRates() {
151141
exchangeRatesStream?.cancel()
142+
ratesCollectionJob?.cancel()
152143
}
153144

154-
override fun rateFor(currencyCode: CurrencyCode): Rate? = rates.rateFor(currencyCode)
145+
override fun rateFor(currencyCode: CurrencyCode): Rate? = verifiedStateManager.rateFor(currencyCode)
155146

156-
override fun rateForUsd(): Rate = rates.rateForUsd()
147+
override fun rateForUsd(): Rate = verifiedStateManager.rateFor(CurrencyCode.USD) ?: Rate.oneToOne
157148

158149
override fun rateToUsd(from: CurrencyCode): Rate? {
159-
val fromRate = rates.rateFor(from) ?: return null
150+
val fromRate = verifiedStateManager.rateFor(from) ?: return null
160151

161152
return Rate(
162153
fx = 1 / fromRate.fx,
@@ -166,35 +157,31 @@ internal class OpenCodeExchange @Inject constructor(
166157

167158
private fun streamRates() {
168159
stopStreamingRates()
160+
// Start the stream so CurrencyService populates VerifiedProtoManager
169161
exchangeRatesStream = scope.launch {
170162
currencyController.streamLiveMintData(this, mints, tag = "exchange")
171-
.filterIsInstance<LiveMintDataResponse.ExchangeRates>()
172-
.collect { exchangeData ->
163+
.collect { /* stream is consumed to keep it alive; rates are saved to VerifiedProtoManager by CurrencyService */ }
164+
}
165+
// Observe rates from VerifiedProtoManager (single source of truth)
166+
ratesCollectionJob = scope.launch {
167+
verifiedStateManager.observeRates()
168+
.distinctUntilChanged()
169+
.collect { rates ->
170+
if (rates.isEmpty()) return@collect
173171
trace(tag = "Exchange", message = "Rates updated")
174-
val associatedRates = exchangeData.rates.associateBy { it.rate.currency }
175-
rates = RatesBox(
176-
dateMillis = Clock.System.now().toEpochMilliseconds(),
177-
rates = associatedRates.mapValues { it.value.rate },
178-
)
179-
updateRates()
172+
updateRates(rates)
180173
}
181174
}
182175
}
183176

184-
private fun updateRates() {
185-
if (rates.isEmpty) {
186-
return
187-
}
188-
189-
val balanceRate = balanceCurrency?.let { rates.rateFor(it) }
177+
private fun updateRates(rates: Map<CurrencyCode, Rate>) {
178+
val balanceRate = balanceCurrency?.let { rates[it] }
190179
val balanceChanged = _balanceRate.value != balanceRate
191180
if (balanceChanged) {
192181
_balanceRate.value = if (balanceRate != null) {
193182
trace(
194183
tag = "Background",
195-
message = "Updated the local currency: $balanceCurrency, " +
196-
"Staleness ${(System.currentTimeMillis() - rates.dateMillis).minutes} mins, " +
197-
"Date: ${Date(rates.dateMillis)}",
184+
message = "Updated the local currency: $balanceCurrency",
198185
type = TraceType.Process
199186
)
200187
balanceRate
@@ -204,20 +191,17 @@ internal class OpenCodeExchange @Inject constructor(
204191
message = "local:: Rate for $balanceCurrency not found. Defaulting to USD.",
205192
type = TraceType.Process
206193
)
207-
rates.rateForUsd()
194+
rates[CurrencyCode.USD] ?: Rate.oneToOne
208195
}
209196
}
210197

211-
212-
val entryRate = entryCurrency?.let { rates.rateFor(it) }
198+
val entryRate = entryCurrency?.let { rates[it] }
213199
val entryChanged = _entryRate.value != entryRate
214200
if (entryChanged) {
215201
_entryRate.value = if (entryRate != null) {
216202
trace(
217203
tag = "Background",
218-
message = "Updated the entry currency: $entryCurrency, " +
219-
"Staleness ${System.currentTimeMillis() - rates.dateMillis} ms, " +
220-
"Date: ${Date(rates.dateMillis)}",
204+
message = "Updated the entry currency: $entryCurrency",
221205
type = TraceType.Process
222206
)
223207
entryRate
@@ -227,7 +211,7 @@ internal class OpenCodeExchange @Inject constructor(
227211
message = "entry:: Rate for $entryCurrency not found. Defaulting to USD.",
228212
type = TraceType.Process
229213
)
230-
rates.rateForUsd()
214+
rates[CurrencyCode.USD] ?: Rate.oneToOne
231215
}
232216
}
233217

@@ -236,36 +220,7 @@ internal class OpenCodeExchange @Inject constructor(
236220
tag = "Background",
237221
message = "Updated rates",
238222
type = TraceType.Process,
239-
metadata = {
240-
"date" to Instant.fromEpochMilliseconds(rates.dateMillis)
241-
.format("yyyy-MM-dd HH:mm:ss")
242-
}
243223
)
244224
}
245225
}
246-
}
247-
248-
private data class RatesBox(
249-
val dateMillis: Long,
250-
val rates: Map<CurrencyCode, Rate>,
251-
) {
252-
constructor(dateMillis: Long, rates: List<Rate>) : this(
253-
dateMillis,
254-
rates.associateBy { it.currency },
255-
)
256-
257-
258-
val isEmpty: Boolean
259-
get() = rates.isEmpty()
260-
261-
fun rateFor(currencyCode: CurrencyCode): Rate? = rates[currencyCode]
262-
263-
fun rateFor(currency: Currency): Rate? {
264-
val currencyCode = CurrencyCode.tryValueOf(currency.code)
265-
return currencyCode?.let { rates[it] }
266-
}
267-
268-
fun rateForUsd(): Rate {
269-
return rates[CurrencyCode.USD] ?: Rate.oneToOne
270-
}
271226
}

services/opencode/src/main/kotlin/com/getcode/opencode/internal/manager/VerifiedProtoManager.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package com.getcode.opencode.internal.manager
33
import com.codeinc.opencode.gen.currency.v1.CurrencyService
44
import com.getcode.opencode.internal.network.extensions.toMint
55
import com.getcode.opencode.model.financial.CurrencyCode
6+
import com.getcode.opencode.model.financial.Rate
67
import com.getcode.solana.keys.Mint
8+
import kotlinx.coroutines.flow.Flow
79
import kotlinx.coroutines.flow.MutableStateFlow
10+
import kotlinx.coroutines.flow.map
811
import kotlinx.coroutines.flow.update
912
import javax.inject.Inject
1013
import javax.inject.Singleton
@@ -54,6 +57,44 @@ class VerifiedProtoManager @Inject constructor() {
5457
this.reserveStates.update { it + incoming }
5558
}
5659

60+
/**
61+
* Derives [Rate] objects directly from the cached verified exchange rate protos.
62+
* This is the single source of truth for exchange rates — consumers should use
63+
* this instead of maintaining separate rate caches.
64+
*/
65+
fun observeRates(): Flow<Map<CurrencyCode, Rate>> = exchangeData.map { map ->
66+
map.mapValues { (_, proto) ->
67+
Rate(
68+
fx = proto.exchangeRate.exchangeRate,
69+
currency = CurrencyCode.tryValueOf(proto.exchangeRate.currencyCode)
70+
?: CurrencyCode.USD
71+
)
72+
}
73+
}
74+
75+
/**
76+
* Returns the current [Rate] for the given [currencyCode] directly from the
77+
* verified proto cache, or null if not available.
78+
*/
79+
fun rateFor(currencyCode: CurrencyCode): Rate? {
80+
val proto = exchangeData.value[currencyCode] ?: return null
81+
return Rate(
82+
fx = proto.exchangeRate.exchangeRate,
83+
currency = CurrencyCode.tryValueOf(proto.exchangeRate.currencyCode)
84+
?: CurrencyCode.USD
85+
)
86+
}
87+
88+
fun rates(): Map<CurrencyCode, Rate> {
89+
return exchangeData.value.mapValues { (_, proto) ->
90+
Rate(
91+
fx = proto.exchangeRate.exchangeRate,
92+
currency = CurrencyCode.tryValueOf(proto.exchangeRate.currencyCode)
93+
?: CurrencyCode.USD
94+
)
95+
}
96+
}
97+
5798
fun reset() {
5899
exchangeData.value = emptyMap()
59100
reserveStates.value = emptyMap()

0 commit comments

Comments
 (0)